Docker アプリケーションのケース - コード送信メッセージを Qiwei グループにプッシュする

序文

前回の記事 でdocker について学習した後は、docker のいくつかの重要な概念と動作原理、docker の利点、イメージの作成方法、イメージの実行方法、イメージの再生成方法、Docker Desktop のインストール方法と使用方法、および docker で一般的に使用されるいくつかのコマンドについて、予備的な理解ができたことでしょう。今日はさらに一歩進んで、Windows オペレーティング システムで Docker を使用してコード送信メッセージをエンタープライズ マイクログループにプッシュするアプリケーション ケースを実装し、Docker 統合の使用を強化します。

原理

コーディング コード ウェアハウス イベント メッセージを Qiwei グループにプッシュするには、次の 2 つの背景知識を理解する必要があります。

  • Qiwei グループでチャットボットを作成すると、Webhook アドレスが取得されます。Qiwei ロボットが解析できるメッセージ形式でこのアドレスにメッセージを送信すると、グループ内のロボットがこのメッセージを Qiwei グループにプッシュすることがわかります。
  • コーディングのさまざまなイベントにより特定のメッセージが生成され、指定されたサーバーにプッシュできます。

たとえば、次のメッセージはコード マージ イベントによってプッシュされており、メッセージ本文が比較的長く、多くの情報が含まれていることがわかります。

{
  "event": "", // 事件代码
  "eventName": "", // 事件名
  "mergeRequest": {
    "id": "", // 合并请求编号
    "html_url": "", // 合并请求访问地址
    "patch_url": "", // 合并请求 patch 地址
    "diff_url": "", // 合并请求 diff 地址
    "number": "", // 合并请求资源编号
    "state": "", // 合并请求状态
    "title": "", // 合并请求标题
    "body": "", // 合并请求内容
    "user": {
      "id": "", // 创建者编号
      "avatar_url": "", // 创建者头像
      "html_url": "", // 创建者主页地址
      "name": "", // 创建者名称
      "name_pinyin": "" // 创建者名称(拼音)
    },
    "created_at": "", // 合并请求创建时间
    "updated_at": "", // 合并请求更新时间
    "merge_commit_sha": "", // 合并请求 commit
    "merged": "", // 合并请求是否合并
    "comments": "", // 合并请求评论数
    "commits": "", // 合并请求提交数
    "additions": "", // 合并请求新增数
    "deletions": "", // 合并请求删除数
    "changed_files": "", // 合并请求文件变化数
    "head": {
      "ref": "", // 分支名称
      "sha": "", // 最后一个 commit sha
      "user": {
        "id": "", // 提交者编号
        "avatar_url": "", // 提交者头像
        "html_url": "", // 提交者主页地址
        "name": "", // 提交者名称
        "name_pinyin": "" // 提交者名称(拼音)
      },
      "repo": {
        "id": "", // 代码仓库编号
        "name": "", // 代码仓库标识
        "full_name": "", // 完整路径
        "owner": {
          "id": "", // 所有者编号
          "avatar_url": "", // 所有者头像
          "html_url": "", // 所有者主页地址
          "name": "", // 所有者名称
          "name_pinyin": "" // 所有者名称(拼音)
        },
        "private": "", //是否私有仓库
        "html_url": "", //代码仓库访问地址
        "description": "", // 代码仓库描述
        "fork": "", // 是否可以被 fork
        "created_at": "", // 创建时间
        "updated_at": "", // 更新时间
        "clone_url": "", // HTTP 克隆地址
        "ssh_url": "", // SSH 克隆地址
        "default_branch": "", // 默认分支
        "vcs_type": "" // 代码仓库类型
      }
    },
    "base": {
      "ref": "", // 分支名称
      "sha": "", // 最后一个 commit sha
      "user": {
        "id": "", // 提交者编号
        "avatar_url": "", // 提交者头像
        "html_url": "", // 提交者主页地址
        "name": "", // 提交者名称
        "name_pinyin": "" // 提交者名称(拼音)
      },
      "repo": {
        "id": "", // 代码仓库编号
        "name": "", // 代码仓库标识
        "full_name": "", // 完整路径
        "owner": {
          "id": "", // 所有者编号
          "avatar_url": "", // 所有者头像
          "html_url": "", // 所有者主页地址
          "name": "", // 所有者名称
          "name_pinyin": "" // 所有者名称(拼音)
        },
        "private": "", //是否私有仓库
        "html_url": "", //代码仓库访问地址
        "description": "", // 代码仓库描述
        "fork": "", // 是否可以被 fork
        "created_at": "", // 创建时间
        "updated_at": "", // 更新时间
        "clone_url": "", // HTTP 克隆地址
        "ssh_url": "", // SSH 克隆地址
        "default_branch": "", // 默认分支
        "vcs_type": "" // 代码仓库类型
      }
    }
  },
  "reviewers": [
    {
      "user_id": "", // 评审者编号
      "user_name": "", // 评审者用户名
      "user_email": "", // 评审者邮箱
      "user_global_key": "", // 评审者GK
      "url": "", // 评审者主页地址
      "avatar_url": "" // 评审者头像
    }
  ],
  "watchers": [
    {
      "user_id": "", // 关注者编号
      "user_name": "", // 关注者用户名
      "user_email": "", // 关注者邮箱
      "user_global_key": "", // 关注者GK
      "url": "", // 关注者主页地址
      "avatar_url": "" // 关注者头像
    }
  ],
  "repository": {
    "id": "", // 代码仓库编号
    "name": "", // 代码仓库标识
    "full_name": "", // 完整路径
    "owner": {
      "id": "", // 所有者编号
      "avatar_url": "", // 所有者头像
      "html_url": "", // 所有者主页地址
      "name": "", // 所有者名称
      "name_pinyin": "" // 所有者名称(拼音)
    },
    "private": "", //是否私有仓库
    "html_url": "", //代码仓库访问地址
    "description": "", // 代码仓库描述
    "fork": "", // 是否可以被 fork
    "created_at": "", // 创建时间
    "updated_at": "", // 更新时间
    "clone_url": "", // HTTP 克隆地址
    "ssh_url": "", // SSH 克隆地址
    "default_branch": "", // 默认分支
    "vcs_type": "" // 代码仓库类型
  },
  "sender": {
    "id": "", // 发送者编号
    "avatar_url": "", // 发送者头像
    "url": "", // 发送者主页地址
    "html_url": "", // 发送者主页地址
    "name": "", // 发送者名称
    "name_pinyin": "" // 发送者名称(拼音)
  },
  "project": {
    "id": "", // 项目编号
    "name": "", // 项目标识
    "display_name": "", // 项目名
    "description": "", // 项目描述
    "icon": "", // 项目图标
    "url": "" // 项目访问地址
  },
  "team": {
    "id": "", // 团队编号
    "domain": "", // 团队域名
    "name": "", // 团队名
    "name_pinyin": "", // 团队名(拼音)
    "introduction": "", // 团队简介
    "avatar": "", // 团队图标
    "url": "" // 团队访问地址
  }
}

上に示したように、各コーディング コード ウェアハウス イベント タイプのメッセージ本文が長すぎるため、特定のシナリオのメッセージ プッシュ条件をカスタマイズできません。これは私たちが望むものではありません。したがって、それを処理し、受信したコーディング コード ウェアハウス イベント メッセージを予期されるメッセージ形式にフォーマットし、それを Qiwei グループ ロボットに送信して Qiwei グループにプッシュするための中継サーバーが必要です。プロセス全体のデータの流れを次の図に示します。

名前のないファイル.png

実装手順

達成するために 3 つのステップに分かれています。

  1. コード ウェアハウス イベント メッセージをトランジット サーバーにプッシュできるようにコーディングで構成します。
  2. ロボットをエンタープライズ マイクロ グループに追加し、ロボットの Webhook アドレスを取得します。
  3. 転送サーバーの機能を開発します。コーディングによってプッシュされたメッセージを分析し、メッセージをフォーマットして、エンタープライズ マイクロ グループ ロボットに送信します。

このようにして、コーディング時にコードがコード ウェアハウスにプッシュされ、マージ リクエストが開始されると、対応するイベントがトリガーされ、コーディングによってコード ウェアハウスの操作情報がトランジット サーバーにプッシュされます。メッセージを受信したトランジット サーバーは、コンテンツをフォーマットしてエンタープライズ マイクログループ ロボットに送信します。エンタープライズ マイクログループのメンバーは、コード送信マージ メッセージを確認できます。

Step1 Coding推送消息配置

登陆coding,找到配置菜单入口项目设置,点击之后,会进入到二级导航菜单

画像.png

找到开发者选项并点击,点击之后,再点击右上角的新建 ServiceHook菜单

画像.png

新建 ServiceHook第一步中选择Webhook

画像.png

第二步中推送事件类型选择代码推送合并请求事件

画像.png

最后一步,填写推送消息的服务器地址,配置好之后,可以点击一下发送测试PING事件测试一下, 配置的服务器地址能否收到测试消息。coding代码仓库事件推送的消息内容可点击这里查看

画像.png

Step2 企微群机器人配置

需要具有群主权限, 点击右上角的..., 找到添加群机器人菜单

画像.png

点击新创建一个机器人,输入机器人名称后,机器人就创建好了。

画像.png

企微群机器人接收消息的Webhook地址会显示在下方

画像.png

重点看一下消息推送格式,项目中用到了两种格式,一种是text类型,一种是markdown类型。@群里的成员有两种配置方法,一种是在mentioned_list列表中配置用户名,另一种是在mentioned_mobile_list列表中配置手机号,两种方式二选其一。

{
    "msgtype": "text",
    "text": {
        "content": "广州今日天气:29度,大部分多云,降雨概率:60%",
        "mentioned_list":["wangqing","@all"],
        "mentioned_mobile_list":["13800001111","@all"]
    }
}
{
  "msgtype": "markdown",
  "markdown": {
    "content": "实时新增用户反馈<font color=\"warning\">132例</font>,请相关同事注意。\n> 
    类型:<font color=\"comment\">用户反馈</font>\n> 
    普通用户反馈:<font color=\"comment\">117例</font>\n> 
    VIP用户反馈:<font color=\"comment\">15例</font>"
  }
}

除了推送文本消息,也能推送图片,图文, 文件,模板卡片类型的消息,每种消息类型配置的详细参数说明,可在机器人配置说明菜单下查看。

画像.png

中转服务器功能实现

中转服务复用了项目中一个集大成的后台服务,这个集大成的后台服务业务逻辑采用nest实现, 依赖的mysql数据库服务,rabbitmq消息队列,redis缓存,postgres数据库, nginxweb服务,全部放在docker容器中。这样做的好处是,可以不必在本地机器上安装配置这些后台依赖的软件与服务,把它们制作成docker镜像,让它们运行在docker中,也能提供同样的服务。在分布式架构下,拓展迁移很方便。由于需要创建和启动的容器较多,所以我们采用docker-compose方式管理这些服务。

Docker-compose 是用于定义和运行多容器 Docker 应用程序的编排工具。使用 docker-compose 后不再需要逐一创建和启动容器。使用 YML 文件来配置应用程序需要的所有服务,只需一条命令,就可以从 YML 文件配置中创建并启动所有服务。此项目docker-compose.yml配置如下所示:

version: '3'
services:
  mysql:
    image: mysql:5.7
    restart: always
    ports:
      - '3306:3306'
    environment:
      MYSQL_ROOT_PASSWORD: hello-mysql
  rabbitmq:
    image: rabbitmq:management
    restart: always
    ports:
      - '4369:4369'
      - '5671:5671'
      - '5672:5672'
      - '15671:15671'
      - '15672:15672'
      - '25672:25672'
  redis:
    image: redis:alpine
    restart: always
    ports:
      - '6379:6379'
  postgres:
    image: postgres
    restart: always
    ports:
      - '5432:5432'
    environment:
      POSTGRES_PASSWORD: hello-postgres
  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/log:/var/log/nginx
      - ./nginx/www:/var/www

上面的docker-compose每个属性的含义如下表所示:

属性名 含义
version 这个定义关乎docker的兼容性。Compose 文件格式有3个版本,分别为1, 2.x 和 3.x 。目前主流的为 3.x 其支持 docker 1.13.0 及其以上的版本
services 定义了服务的配置信息,包含应用于该服务启动的每个容器的配置
image 指定为镜像名称或镜像 ID。如果镜像在本地不存在,docker将会尝试拉取这个镜像。项目中用的的这几个镜像都是从远程docker仓库拉取的
restart 启动策略,有4个取值:no(表示容器退出时,docker不自动重启容器),on-failure[:times](若容器的退出状态非0,则docker自动重启容器,还可以指定重启次数,若超过指定次数未能启动容器则放弃),always(容器退出时总是重启),unless-stopped(容器退出时总是重启,但不考虑Docker守护进程启动时就已经停止的容器)。
ports 暴露端口信息。使用宿主端口:容器端口 (HOST:CONTAINER) 格式,或者仅仅指定容器的端口(宿主将会随机选择端口)都可以。项目中采用的是HOST:CONTAINER格式
environment 服务所需的环境变量
volumes 数据卷所挂载路径设置。可以设置为宿主机路径(HOST:CONTAINER)或者数据卷名称(VOLUME:CONTAINER),如这句./nginx/conf.d:/etc/nginx/conf.d , 将项目下的./nginx/conf.d映挂载到docker的/etc/nginx/conf.d路径下

docker并不包含docker-compose工具,docker-compose需要单独安装,如果你的编码IDE是VSCode的话,可以在应用商店搜索Docker Compose扩展安装使用。

画像.png

docker-compose常用的命令有:

# 默认使用docker-compose.yml构建镜像
docker-compose build
# 不带缓存的构建
docker-compose build --no-cache 
# 指定不同yml文件模板用于构建镜像
docker-compose build -f docker-compose1.yml

# 列出Compose文件构建的镜像
docker-compose images                          

# 启动所有编排容器服务(守护模式)
docker-compose up -d

# 查看正在运行中的容器
docker-compose ps 

# 查看所有编排容器,包括已停止的容器
docker-compose ps -a

# 进入指定容器执行命令
docker-compose exec nginx bash 
docker-compose exec web python manage.py migrate --noinput

# 查看web容器的实时日志
docker-compose logs -f web

# 停止所有up命令启动的容器
docker-compose down 
# 停止所有up命令启动的容器,并移除数据卷
docker-compose down -v

# 重新启动停止服务的容器
docker-compose restart web

# 暂停web容器
docker-compose pause web

# 恢复web容器
docker-compose unpause web

# 删除web容器,删除前必需停止stop web容器服务
docker-compose rm web  

# 查看各个服务容器内运行的进程 
docker-compose top   

集大成的业务服务依赖docker容器服务,所以首先要启动docker容器服务。先启动windows桌面版docker,接着执行docker-compose up命令启动所有编排容器服务,命令执行完成后,可以在桌面版的docker中查看容器服务的运行状态,从下图可以看出,本项目中依赖的5个docker服务均已正常启动。

画像.png

现在启动集大成的业务服务:执行yarn start,如果控制台没有任何报错的话,说明任务启动正常。

画像.png

业务服务正常启动后,在这个集大成的后台服务中加上一段给企微群机器人推送消息的功能:

为了防止中转服务被恶意攻击,要加一个签名校验逻辑, 使用Node.js 的 crypto 模块 createHmac() 方法创建一个HMAC-SHA1 实例,传入密钥 process.env.CODING_WEBHOOK_SECRET 和要计算的数据 request.rawBody。然后使用 update() 方法将数据添加到 HMAC 实例中,最后使用 digest() 方法计算出 HMAC-SHA1 的值,并以十六进制字符串的形式输出。与配置在coding上中的签名令牌做比较,进行签名校验。签名校验失败抛出异常,签名校验通过,对接收到的代码仓库事件消息数据进行处理。

  // 接收 coding webhook 的回调请求并处理
  @Post('coding-webhook')
  async codingWebhook(@Req() request: any, @Body() webhookData: any) {
    const webhookDataHash = crypto
      .createHmac('sha1', process.env.CODING_WEBHOOK_SECRET)
      .update(request.rawBody)
      .digest('hex');
    if (
      !request.headers['x-coding-signature'] ||
      request.headers['x-coding-signature'] !== `sha1=${webhookDataHash}`
    ) {
      this.logger.error(
        `[ToolkitModule][ToolkitController] codingWebhook auth error: sha1=${webhookDataHash}`,
      );
      throw new HttpException('UNAUTHORIZED', HttpStatus.UNAUTHORIZED);
    }

    // 向机器人推送消息
    this.sendBotMsg(webhookData);

    return 'ok';
  }

只有符合条件的分支和事件才触发消息通知,这些信息是从配置文件中读取的。

  sendBotMsg(webhookData) {
    // 分支名和事件符合条件才推送消息
    if (!this.isCanPostMsg(webhookData)) {
      return;
    }

    // 按照企微消息格式封装推送消息
    const msgArr = this.packageMsg(webhookData);

    if (msgArr.length) {
      // 向机器人推送消息
      this.postMessage(webhookData, msgArr);
    }
  }

上面的packageMsg方法的功能是封装推送消息,解析coding推送过来的代码仓库事件消息,将消息封装成企微群机器人支持的markdowntext格式。markdown格式用于展示仓库合并信息,text格式用于@群成员。

 
  packageMsg(webhookData): IQywxMsgProps[] {
    // 推送消息类型的格式
    const msgArr: IQywxMsgProps[] = [
      {
        msgtype: 'markdown',
        markdown: {
          content: '',
        },
      },
    ];
    // 拼接推送消息内容的数组
    const contentArr = [];

    let eventShortTitle = '';
    const event = webhookData.event;
    const action = webhookData.action;
    const sourceBranch = webhookData?.mergeRequest?.head?.ref;
    const targetBranch = webhookData?.mergeRequest?.base?.ref;
    const repoName = webhookData?.repository?.name;
    // 当前动作触发人姓名,创建 mr 时是提 mr 的人,合并 mr 是是合并人
    const senderName = webhookData?.sender?.name;
    const mrUserName = webhookData?.mergeRequest?.user?.name;
    const mrReviewerUserName = webhookData?.mergeRequest?.merged_by?.name;
    const title = webhookData?.mergeRequest?.title;
    const state = webhookData?.mergeRequest?.state;
    const labels = webhookData?.mergeRequest?.labels;
    const createdAt = webhookData?.mergeRequest?.created_at;
    const updatedAt = webhookData?.mergeRequest?.updated_at;
    const mergedAt = webhookData?.mergeRequest?.merged_at;
    const viewUrl = webhookData?.mergeRequest?.html_url;

    contentArr.push(
      `${eventShortTitle}标题: <font color="comment">${title}</font>`,
    );
    contentArr.push(`应用名称: <font color="comment">${repoName}</font>`);
    contentArr.push(
      `来源分支: <font color="comment">**${sourceBranch}**</font>`,
    );
    contentArr.push(
      `目标分支: <font color="comment">**${targetBranch}**</font>`,
    );
    contentArr.push(`当前状态: <font color="warning">${state}</font>`);

    // merge事件类型才展示合并人员
    if (event === 'GIT_MR_MERGED') {
      contentArr.push(
        `合并人员: <font color="comment">${mrReviewerUserName}</font>`,
      );
    }

    if (['GIT_MR_UPDATED', 'GIT_MR_MERGED'].includes(event)) {
      // 更新和合并请求的时间取值字段不一样
      const time = {
        GIT_MR_UPDATED: updatedAt,
        GIT_MR_MERGED: mergedAt,
      };

      time[event] &&
        contentArr.push(
          `${eventShortTitle}时间: <font color="comment">${format(
            new Date(time[event]),
            'yyyy-MM-dd HH:mm:ss',
          )}</font>`,
        );
    }

    contentArr.push(`创建人员: <font color="comment">${mrUserName}</font>`);
    contentArr.push(
      `创建时间: <font color="comment">${format(
        new Date(createdAt),
        'yyyy-MM-dd HH:mm:ss',
      )}</font>`,
    );


    contentArr.push(`查看地址: ${viewUrl}`);
    msgArr[0].markdown.content = contentArr.join('\n>');

    // 查找是否配置了@群成员规则
    const mentionedRule = this.findMentionedConfig(webhookData);
    // console.log(mentionedRule);
    // 如果返回的对象值不为空,说明查找到@群成员规则
    if (mentionedRule.mentionedList) {
      // 创建更新代码合并请求|评审未通过,通知消息传入参数为代码合并请求地址
      // 代码评审通过以及代码已合并,通知消息传入参数为要更新代码的分支
      const info =
        ['push', 'bad'].includes(action) || event === 'GIT_MR_CREATED'
          ? viewUrl
          : `${repoName}仓库${targetBranch}`;
      // 配置了@群成员规则,则在推送信息中添加@群成员字段
      msgArr.push({
        msgtype: 'text',
        text: {
          content: mentionedRule.joinContent(info),
          mentioned_list: mentionedRule.mentionedList(mrUserName),
        },
      });
    }

    return msgArr;
  }

上面的postMessage方法用于向微信群推送消息。一般会存在多个群,不同仓库的消息发送到不同的群。

  // 推送消息
  postMessage(webhookData: any, msgArr: IQywxMsgProps[]) {
    // 获取消息推送地址
    const botApiUrls = this.getBotApiUrls(webhookData);
    // 可向多个地址推送消息
    botApiUrls.forEach((apiUrl) => {
      msgArr.forEach((msg) => {
        this.qywxBotService.postMsg(apiUrl, msg);
      });
    });
  }

获取的相应企微群机器人的推送地址后,调用this.qywxBotService.postMsg方法,将封装好的消息推送给企微机器人。qywxBotService的功能实现如下:

export class QywxBotService {
  constructor(private httpService: HttpService, private logger: Logger) {}

  // apiUrl--消息推送地址
  // data--要推送的消息
  postMsg(apiUrl: string, data: any): void {
    this.httpService.post(apiUrl, data).subscribe({
      // eslint-disable-next-line @typescript-eslint/no-empty-function
      next: (result) => {},
      error: (error) => {
        this.logger.error(
          '[QywxBotModule][QywxBotService] postMsg error: ',
          error,
          data,
        );
      },
    });
  }
}

推送到企微群的消息效果如下图所示:

画像.png

最后

本文讲述了如何把coding代码仓库事件消息推送到指定企微群的实现原理与方法,在coding上该做哪么配置,在企微群该做哪些配置,整个过程的数据流动过程是怎样的,至于中转服务器这部分,不是必须的。除非有特殊的消息推送规则。比如什么分支什么事件需要@群里哪些人关注,才需要消息中转服务。如果真要做,你可以采用更轻量化的方式实现,不必参考文中的方法。其它仓库类型的消息推送方式(比如说GitLab),也和本文的差不多,可以触类旁通。如果你能看到这里,恭喜你,又get到了一个新技能。

おすすめ

転載: juejin.im/post/7259681705970909241