需求: 团队会有定期举行的地会议,每个会议又有不同的轮值主持人。所以希望在每个会议开始之前能让大家的企业微信群里面收到提醒,比如:今天的某某会议的主持人是某某。
计划:
- 通过调研,发现企业微信是有现成的api的,可以直接向这个api发送对应的request即可
- 为了可靠性考虑,整个应用应该托管在云平台上
- 为了方便应用在云平台上运行,这里我们计划使用circleCI把应用打包成一个docker 镜像
- 因为需要记录上一次主持人的id,所以应用有持久化的需求,通过调研,对于小批量大访问频率的数据可以采用google cloud storage的bucket来存储
前置条件:
- 在google cloud上有一个现成的project并且本人有对于project的编辑权限
- 在对应的project上生成有service account,即适用于机器的账户,并且配置有云平台的编辑权限
实现:
- 根据企业微信的文档,在群里面创建机器人,并且获取对应的地址。这里需要注意,这个地址一定要避免存储在github等公有仓库中,因为任何人获取到这个地址,就可以任意在目标群中发送消息
- 企业微信文档里面给出的说明是使用curl来发送请求的,这里可以先测试一下,确定没问题再往下
- 由于我们的业务还是有些逻辑的,所以我使用nodejs来实现应用主体。这时候就需要把curl翻译成对应的代码。其中,采用了node自带的request来实现。
const sendMessage = (message, sendKey) => {
const requestContent = message.reduce((acc, curr) => {
return acc + 'Today, the facilitator of the ' + curr.name + ' is ' + curr.facilitatorName + '\n'
}, '')
const option = {
hostname: 'qyapi.weixin.qq.com',
path: sendKey,
method: 'POST',
}
var req = https.request(option,
function (res) {
console.log('STATUS: ' + res.statusCode);
console.log('HEADERS: ' + JSON.stringify(res.headers));
res.setEncoding('utf8');
res.on('data', function (chunk) {
console.log('BODY: ' + chunk);
});
});
req.on('error', function (e) {
console.log('problem with request: ' + e.message);
});
req.setHeader('Content-Type', 'application/json')
req.write(JSON.stringify(
{
msgtype: "text",
text: {
content: requestContent
}
}
))
req.end();
}
这里实现了一个简单的函数用来发送对应的内容到企业微信api。其中,apiKey就是前面提到的生成企业微信机器人后拿到的url的path,它是截取了domain名后面的部分。当前为了敏捷,可以先修改这个函数,让他发送一个hardcode的固定的信息到一个试验的微信群
- 接下来是尝试把这段代码打包成镜像push到我们的google cloud云平台上让他能给群里发一个‘hallo world’。dockerFile比较简单,首先声明基础镜像为node16,因为功能也比较简单,所以使用了alpine版本来缩小体积。接着声明应该在打包镜像之前运行一下npm install来根据package.json拉取所有的镜像并且打包。最后声明了在docker跑起来的时候应该跑哪些命令
FROM node:16-alpine3.17
WORKDIR /app
USER root
COPY . /app
RUN npm install
ENTRYPOINT ["/bin/sh", "-c" , "node app.js"]
- 本地运行打包好的镜像,成功发送请求后,尝试使用ciecleCI将镜像部署到谷歌云平台上。 这里摘取主要步骤: 认证
gcloud auth activate-service-account --key-file=<前面生成的service key>
gcloud --quiet config set project "<<project的id>>"
gcloud --quiet config set compute/zone "<<计算机的时区>>"
gcloud --quiet container clusters create "<<想要创建的新的cluster的名字>>" --num-nodes=1
配置cluster 这里采用文件的方法,将配置声明成一个yaml文件来配置集群
apiVersion: batch/v1
kind: CronJob
metadata:
labels:
app: bot
name: bot
namespace: bot
spec:
schedule: "20 01 * * 1-5"
jobTemplate:
spec:
template:
spec:
serviceAccountName: bot
nodeSelector:
iam.gke.io/gke-metadata-server-enabled: "true"
containers:
- image: $FULL_DOCKER_IMAGE_NAME
imagePullPolicy: IfNotPresent
name: bot
resources: {}
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
env:
- name: APP_ENV
value: <<parameters.env>>
restartPolicy: OnFailure
这里是一个类型为cronjob的资源,它在每周工作日的零时区的一点二十被触发。
其中值得注意的是,servcieAccountName这个选项。它指定了k8s中的servcieaccount的名字。这个目的是用于k8s中的servcieaccount与谷歌云中的serviceaccount进行绑定。原因是:在k8s和谷歌云中同时存在serviceaccount这个的概念,此时我们的代码跑在k8s中,k8s托管在谷歌云平台内,他持有的是k8s的serviceaccount。然而,他有需求要访问谷歌的资源,例如前述提到的google cloud storage。此时,谷歌并不认识这个serviceaccount,而是只能识别谷歌的serviceaccount。所以需要一次绑定。这里指定的servcieAccountName就是我们绑定的时候创建的k8s的serviceaccount的名字。绑定的步骤在这里
之后将打包好的docker镜像文件push到gcp的目标位置:gcr.io/<<projectId>>/<imagetag>
至此,我们实现了一个每天可以定时trigger的cronjob,内容是定时发送固定内容到我们的微信群
- 接下来是对外读取存储。经过调研,发现谷歌bucket比较适合我们的使用场景,并且价格适中。这里使用bucket的读取到内存的api。
const getDataFromBucket = async () => {
const storage = new Storage({});
const [rotationContent] = await storage.bucket('bot').file('rotations.json').download();
const [facilitatorMapperRowContent] = await storage.bucket('bot').file('users.json').download();
const rotationsList = JSON.parse(rotationContent.toString()).rotations;
const facilitatorMapper = JSON.parse(facilitatorMapperRowContent.toString());
return { rotationsList, facilitatorMapper: facilitatorMapper.user, sendKey: facilitatorMapper.sendKey }
}
这里有个需要注意的点是,google的bucket api返回的不是我们想象的一个json,而是一个数组,里面解构出来才是文件内容的本体。后来通过tostring方法转换成为string之后就可以使用parse方法转化成为对象了。 取到上一次主持人的id之后就可以根据顺序生成语句并且生成下一个状态存入bucket了。存入的方法与读取类似,调用谷歌的save方法:
storage.bucket('bot').file('rotations.json').save(JSON.stringify({ rotations: nextStatus }));
总结:
- 因为涉及到外部的读取,写入,所以代码中含有大量的异步表达式(函数)。而后续的所有的步骤又是依赖于当前的异步结果。所以这里面用到了大量的await方法,将异步以同步的方式执行。此时会有一个比较难处理的问题,就是await关键字必须用在异步函数体里面(await是处理promise的语法糖,只有async函数才有promise),在这种情况下,最外面的主函数是无法使用await关键字的,因为主函数并没有使用async关键字包裹。这里采用了一个比较取巧的办法,就是把整个函数包成一个异步main函数,并且在最后执行一下这个函数。
- https的request的构建:该对象构建完成之后使用end方法表示结束并且执行。前期有过想发送多条消息的需求,又因为请求头都是一样的,只是content不一样,就想尝试在相同的请求头下多次循环end方法来实现多次发送。结果是行不通的,因为end一旦执行的,该request就不可再编辑。
- dockerfile中的‘CMD‘,’ENTRYPOINT‘'RUN'的区别。‘CMD‘指的是docker启动的默认命令,意味着如果没有特殊指定的话则运行该指令。’ENTRYPOINT‘是docker跑起来之后一定要run的。与前述两个在docker镜像运行时执行的指令不同,'RUN'指的是在docker构建之前执行的指令,多用于打包依赖。在本应用中利用RUN指令执行了一下npm install,用来在打包之前将package.json里面的依赖一并打包到docker里面。