你们是如何敏捷开发 Laravel 私有扩展包的?

起因

最早之前我们的所有端都是在一个项目中,通过 URL Prefix 来实现区分 C 端和 B 端的不同业务逻辑。虽则业务的扩张,和业务的越发复杂,我们不得不将臃肿的单体应用拆分为面向多个端的项目。

为了方便维护,我们将业务中的核心逻辑如数据模型和本地化语言包等,进行拆分和封装,作为单独的扩展包进行提供。

这样,地产业务逻辑的改动,只需要在一个扩展包项目中进行变更就可以了。

问题

拆是拆了,但是也伴随着一些问题:

  1. 这个扩展包一定得是私有的,外部无法访问
  2. 本地开发和测试时如何绕过私有扩展包的发布流程,直接将最新代码应用到本地和测试环境
  3. 在 CI/CD 中如何实现自动区分环境来安装不同来源的包

为了解决上述问题,我们也是调研了很久,最终选定了如下技术作为解决方案:

  • Gitlab
  • Gitlab-Runner
  • Deployer

解决方案

第一个问题其实很好解决,Gitlab 本身支持 Composer Package Registry,我们只需要利用 Gitlab CI/CD 将私有扩展包项目的 release 分支通过 API 请求注册到 Gitlab Composer Package Registry 即可!

在我入职前,团队采用的是 vcs 的方式从 Gitlab 拉取,这种方式实现成本最低,但是潜在的问题是会拉取 .git 信息,随着扩展包的 commit 记录越来越多,导致耗费的时间也就越来越长,甚至是 composer install 的时候内存溢出。

发布扩展包

使用如下 Gitlab CI/CD 配置,自动向 Gitlab Composer Package Registry 发布扩展包:

stages:
  - staging
  - release

staging:
  image: betterde/rsync:latest
  stage: staging
  only:
    refs:
      - develop
  tags:
    - backend
  script:
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" >> ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    # 这里主要是为了在测试环境及时更新 develop 分支的代码,配合 composer 的 path repository 实现从本地加载最新扩展包(目录和分支名称根据实际情况进行替换)
    - ssh -p "$SSH_PORT" -o StrictHostKeyChecking=no root@"$PREVIEW_SERVER" "cd $PACKAGE_PATH/harbor && git checkout develop && git pull"
release:
  image: betterde/deployer:6.8.0
  stage: release
  only:
    refs:
      - tags
  tags:
    - backend
  script:
      # 当我们为扩展包打上 Tag 以后,自动触发 Pipeline 向 Gitlab Package 注册扩展包信息。
    - 'curl --header "Job-Token: $CI_JOB_TOKEN" --data tag="$CI_COMMIT_REF_NAME" "https://gitlab.example.com/api/v4/projects/$CI_PROJECT_ID/packages/composer"'

PACKAGE_PATH 这个环境变量已经在 Package 这个项目组中进行了定义,目录为 /usr/wwwroot/package,你只需要在测试服的这个目录中,克隆扩展包项目,然后切换到开发分支即可。

在生产环境使用

# 这个命令可以在 Gitlab 项目 Package Registry 中获取到。
composer config repositories.<group_id> composer https://gitlab.example.com/api/v4/group/<group_id>/-/packages/composer/

执行上述命令后将会在 composer.json 文件中追加如下内容:

{
  "repositories": {
    "<group_id>": {
      "type": "composer",
      "url": "https://gitlab.example.com/api/v4/group/<group_id>/-/packages/composer/"
    }
  }
}

配置授权信息:

# 将<GITLAB_DOMAIN>替换为实际的 gitlab 域名
composer config gitlab-domains <GITLAB_DOMAIN>

执行玩上述命令后,将会在 composer.json 文件中追加如下内容:

{
  "config": {
    "gitlab-domains": ["<GITLAB_DOMAIN>"]
  }
}

然后再执行如下命令:

# 这里的 GITLAB_DOMAIN 需要和前面一致,然后创建你个人的 Gitlab PERSONAL_ACCESS_TOKEN,并替换 PERSONAL_ACCESS_TOKEN
composer config gitlab-token.<GITLAB_DOMAIN> <PERSONAL_ACCESS_TOKEN>

执行完上述命令后,会在项目目录下生成 auth.json 配置文件:

{
    "gitlab-token": {
        "<GITLAB_DOMAIN>": "<PERSONAL_ACCESS_TOKEN>"
    }
}

到此,私有扩展包的发布和线上安装已经解决了,接下来就是第二个问题,本地开发和测试时如何绕过私有扩展包的发布流程,直接将最新代码应用到本地和测试环境?

使用路径方式在本地扩展包

本地加载扩展包可以参考《Composer 扩展开发:本地运行扩展包》

测试环境加载扩展包

在熟悉本地加载模式以后,其实测试环境也是一样,只不过需要运用 CI/CD 及时部署扩展包 develop 分支到测试环境的指定目录中。

根据环境安装不同来源的扩展包

通过上面的方法我们可以实现在本地加载本地指定目录下的一个扩展包,但是同时产生了一个新的问题——无法按照环境来安装对应的依赖。

比如,当我们本地开发好了以后,需要发版了,这时候你还得还原 composer.json 中的 repositories 得配置,以及 composer.lock 中的内容。这对于开发来说增加了很多心智负担。

我们的解决方案是,在项目目录下创建两套 composer.json 和 composer.lock 文件,一个用于生产,一个用于本地和测试。

composer.dev.json # 开发和测试环境
composer.dev.lock # 开发和测试环境
composer.json # 生产环境
composer.lock # 生产环境

这时候如果我们本地要安装扩展包,只需要使用如下命令:

COMPOSER=composer.dev.json composer install

当我们的扩展包开发完成,并且发布了新版后,我们需要更新本地的 composer.lock 文件,只需要执行如下命令:

composer update --no-install

这么做的好处是即更新了线上的扩展包依赖,又不会覆盖本地的开发环境,如果不加 –no-install 参数的话,会导致 composer 从 Gitlab Composer Package Registry 拉取扩展包并覆盖掉本地的扩展包软连!

对于我们来说,只需要维护 composer.dev.json 和 composer.lock 中其他部分的一致性即可,repositories 的配置随环境的不同而不同。

改造后让我们团队节约出了大量时间和精力,专注于解决其他业务问题。

大家是如何实现私有扩展包的加载呢?欢迎讨论交换思路!

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 18

后面这个多个 composer.json 的方案很巧妙,曾经做的时候,都是在开发阶段单独弄个分支去写 repositories 信息,要合并主干时再处理。

1年前 评论
GeorgeKing (楼主) 1年前

mark一下,碰到同样的问题

1年前 评论
sanders

先说一下我们目前实现的方案吧

file

我们没有考虑太多CI/CD的问题,这里有些路径依赖,团队技术演进的方向一直没有太侧重这方面,也有些许成本的问题。所以生产环境和测试环境的差异较大,开发环境则因人而异。这样的缺点是很明显的,但与楼主的主题关系不大。

采用 composer.json 来管理扩展包

    "repositories": {
        "packagist": {
            "type": "composer",
            "url": "https://mirrors.aliyun.com/composer/"
        },
        "kdd/module-coupon": {
            "type": "vcs",
            "url": "git@xxx.com/kdd-module-coupon-sihui.git"
        },
        "kdd/deploy-sihui": {
            "type": "vcs",
            "url": "git@xxx.com/kdd-deploy-sihui.git"
        },

和楼主采用的方案一样,我们也是通过 repositories 来引入自己的扩展包,只不过我们懒得自己搭 git 服务,所以直接使用了 coding.net 提供的私有仓库。功能上很齐全,还能跟他提供项目协同、文档、测试用例和持续集成功能结合起来用。访问仓库之际使用秘钥对即可。

由于我们采用的是 docker 镜像构建的发布方式,拉取代码时需要将可以访问仓库的私钥配置到凭证里面,并在构建时还原私钥。构建完成会直接将镜像推送到 coding 的制品库(有很多种类,docker镜像仓库是其中之一)。

pipeline {
  agent any
  stages {
    stage('检出deploy-sihui') {
      steps {
        checkout([$class: 'GitSCM',
        branches: [[name: GIT_BUILD_REF_DEPLOY]],
        userRemoteConfigs: [[
          url: GIT_REPO_URL_DEPLOY,
          credentialsId: CREDENTIALS_ID
        ]]])
        sh '''mkdir /root/deploy
cp composer/composer.json /root/deploy/'''
      }
    }

    stage('检出sihui-kernel') {
      steps {
        checkout([$class: 'GitSCM',
        branches: [[name: GIT_BUILD_REF]],
        userRemoteConfigs: [[
          url: GIT_REPO_URL,
          credentialsId: CREDENTIALS_ID
        ]]])
        sh 'cp /root/deploy/composer.json ./'
      }
    }

    stage('构建镜像并推送到 CODING Docker 制品库') {
      steps {
        // 限于篇幅,不做展开
      }
    }

  }
  environment {
    // 限于篇幅,不做展开
  }
}

从以上 jenkinsfile 内容可能看不太出来,但其实代码仓库访问分为三部分,这里能看到对主项目代码仓库的访问和特殊的 deploy 分包的代码仓库访问(这个分包类似胶水,作用是协调分包和主项目之间的配置数据),另一部分其实实在 Dockerfile 里面来进行呈现。

# composer 
FROM composer:2.1.6 as composer
# 私钥文件参数
ARG id_rsa=id_rsa
COPY database/ /app/database/
COPY composer.json /app/
RUN mkdir /root/.ssh
ADD ${id_rsa} /root/.ssh/id_rsa
ARG CACHEFIX=cachefix
RUN touch /root/.ssh/known_hosts \
    && chmod -R 0600 /root/.ssh \
    && ssh-keyscan xxx.net >> /root/.ssh/known_hosts \
    && composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ \
    && cd /app \
    && composer update \
           --optimize-autoloader \
           --ignore-platform-reqs \
           --prefer-dist \
           --no-interaction \
           --no-plugins \
           --no-scripts \
           --no-dev

# laravel
# FROM xxx.com/php:php8-1.3
FROM xxx.net/kdd/php:php8-1.3
ARG LARAVEL_PATH=/www/wwwroot
WORKDIR ${LARAVEL_PATH}

COPY . ${LARAVEL_PATH}
COPY --from=composer /app/vendor/ ${LARAVEL_PATH}/vendor/
COPY docker/entrypoint.sh /usr/local/bin/entrypoint
RUN cd ${LARAVEL_PATH} \
    && cp config/php.ini /usr/local/etc/php/php.ini \
    && php artisan package:discover \
    && chown -R www-data:www-data . \
    && chmod -R +x . \
    && chmod -R 0766 storage \
    && chmod +x /usr/local/bin/entrypoint \
    && cp crontab /var/spool/cron/crontabs/root \
    && crontab /var/spool/cron/crontabs/root \
    && rm id_rsa
STOPSIGNAL SIGTERM
ENTRYPOINT ["/usr/local/bin/entrypoint"]

注意这个 composer 镜像的版本很重要,升级之后将无法使用私钥,我也不知道原因,还请知情的小伙伴不吝赐教。

Dockerfile 分为两部分,第一步就是通过 composer.json 获取全部分包文件,包括咱们自己开发的。第二部分则是将主项目和分包的vendoer目录拷贝到基础镜像。

测试环境为了便于测试和研发工程师去快速调整环境,采用了与基础镜像同源的镜像挂载分包和主项目路径通过 docker-compose 进行编排部署。生产环境则使用 Kubernetes 编排部署,环境差异还是挺大的,但测试环境用不起集群呀(捂脸)。

1年前 评论
Rache1 1年前
sanders (作者) 1年前
Rache1 1年前
sanders (作者) 1年前
Rache1 1年前
GeorgeKing (楼主) 1年前
sanders (作者) 1年前
GeorgeKing (楼主) 1年前
sanders

篇幅有点长,发不出来,这里接上贴

为什么要分包

我们分包的意义在于复用逻辑,由于要考虑为不同的客户(包括我们自己)部署系统,所以需要将公用逻辑放到主项目中,容易产生差异的逻辑放到分包中。客户之间的需求差异,我们可以采用不同分包进行适配,可以使用分包中不同的版本标签来保证分包在不同项目中运行的稳定性。

再说说我们的问题

composer update

可以从 Dockerfile 中发现,我们执行的是 composer update 来进行镜像构建的,这样做肯定会造成各个环境中非自研分包的版本差异。为什么要这样做,请听我诡辩:

不同客户需求的分包和版本都不同,所以 composer.jsoncomposer.lock 都不能简单进入代码仓库。我们选择使用一个特殊的 deploy 分包来保存不同客户项目之间的差异,也包括 composer.json 文件。不保存 composer.lock 文件的主要原因是因为对该文件的编辑太频繁了,每次提测发布都要想着重新生成该文件到deploy分包里,我们直接做了简化处理。

分包逻辑编排

分包和主项目之间,分包与分包之间的编排,我们提到了特殊的 deploy 分包。他无法在不同客户的项目中复用,他主要提供的能力主要有两点:

  1. 维护 composer.json 文件差异,这决定了不同项目都有哪些功能差异;
  2. 保存项目间差异的配置参数;

第一点很容易理解,第二点则可以参考管道模式来理解。分包与主项目,分包与分包之间的逻辑编排,被保存到配置数据中,然后通过管道模式进行调用。

我们直接看样例,比如主项目中注册一个管道入口:

            pipeline()->through(config('pipes.inventory.sku_shop.inventory_handle.update_before', []))
                ->send([
                    'data' => $data,
                    'service' => $this,
                ])
                ->via('updateBefore')
                ->thenReturn();

deploy 分包则有个配置文件 config/pipes/inventory.php

return [
    'sku_shop' => [
        // 库存处理
        'inventory_handle' => [
            // 库存更新之前
            'update_before' => [
                   \Kdd\Module\Xxx\Pipes\Inventory\UpdateBefore::class,
            ],
        ],
    ],
];

对应的 kdd/module-Xxx 分包的逻辑就会被编排进来执行 src/Pipes/Inventory/UpdateBefore.php

namespace Kdd\Module\Xxx\Pipes\Inventory;

use Closure;

class UpdateBefore
{
    /**
     * @param array $passable
     * @param Closure $next
     * @return mixed
     */
    public function updateBefore (array $passable, Closure $next)
    {
        $result = $next($passable);
        // 自己的逻辑
        return $result;
    }
}

小结一下

这套模式已经从2020年开始用到现在,虽然也经历过各种问题,但比较契合我们的使用场景,促使我们持续使用到今天。各位小伙伴如果有任何分包的问题和方案欢迎来一起交流。

1年前 评论
GeorgeKing (楼主) 1年前
mouyong
1年前 评论
GeorgeKing (楼主) 1年前
mouyong (作者) 1年前
小李世界 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!