koa2 生成带场景值参数的带Logo二维码

上次写 Koa 还是2016年刚来公司实习的时候,那时候我还在上大三。转眼见2年过去了,我也正式工作快一年了。 Koa 也从原来比较难用的 generator ,yield 变成了现在的 async await 。真好。

这次文章的背景是,产品需要给公众号拉新。需要生成各种带参数的二维码。这个带参数的二维码可以做很多事情,比如统计新拉的关注是从哪个场景值下来的。比如关注时,不同场景值有不同的公众号回复内容。

在经历了好几次每次手动跟她生成后,我下定了决心,跟产品要了2天排期,给她做一个工具。自动生成带 Logo 的二维码。主要是手工去操作比较繁琐,需要经历下面几步

  1. 先去获得 access_token
  2. 再调用微信创建带参数值二维码的接口qrcode/create,获得 ticket
  3. 再拿 ticket 换二维码

这其中,带参数的二维码又分为了两种,永久和临时二维码。具体接口可以参考公众号的文档, 并且每次产品自己拿到 ticket 换的二维码后,还需要自己去 P logo, 大小也要自己去调。总之比较麻烦。

所以这篇文章,主要就是用 Koa2 生成带场景值参数的公众号二维码。💗🌹


设计

首先我确定了 koa server 端,只做接口层。意思是只用来处理前端的请求。 GET or POST。

前端主要做表单验证,获取产品想要的二维码类型信息等。然后传给后台。后台根据信息,去拿 access_token, 然后调用微信二维码,再添加 logo 等。然后返回给前端。最后的效果是这样的:

结果图

产品可以选择,是临时还是永久,选择是只要链接还是下载图片,是否添加 Logo ,图片的大小等等。

最后如果图片类型选的是【我要生成图片】就可以生成类似下面二维码啦, 如果是点击的 【我只要链接】,那么我只会返回一个带ticket的链接。

生成结果图片

红色的笔部分是为了遮住二维码信息,因为是自己的测试号。主要用到了下面这些 npm 库: koa-router, koa-static, qr-image, request-promise, stream-to-promise, image-size


流程控制

写起来代码没有特别的地方,用 koa-router 做路由,比如产品选择生成链接,还是生成图片,我选择了路由到两个接口。

在配置文件, route.js 中,配置相应路由的请求以及对应的 controller

1
2
3
4
module.exports = {
'post /getLink': 'controllers/createLink',
'post /downloadImage': 'controllers/createImage'
}

这表明:

当前端 post getLink 这个接口时, 用 controllers 下的 createLink 来处理。
当前端 post downloadImage 这个接口时,用 controllers 下的 downloadImage 来处理。

然后再在自己写个注册路由的 middleware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = (app, router) => (
Object.keys(routesConfig).forEach(route => {
const [method, path] = route.split(' ')
const controller = require('../' + routesConfig[route])

if (typeof controller !== 'function' || typeof router[method] !== 'function') {
return
}

if (method === 'post') {
app.use(bodyParser())
}

router.post(path, controller)
})
)

然后在 入口文件中使用这个 middleware 并且注册到 koa-router 上即可。

1
2
3
routeMiddleware(App, router)

App.use(router.routes()).use(router.allowedMethods())

最后是对应的 controller 里处理文件, 比如如下是 downloadImage 的 controller, 把 data 传给了核心处理业务流程的类文件。然后再返回相应的图片即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 创建二维码
* @param ctx
* @returns {Promise<void>}
*/
const Core = require('../server/core')

module.exports = async (ctx) => {
const data = ctx.request.body
const core = new Core(data)

const result = await core.downloadQrCodeImage()

ctx.type = 'png'
ctx.set('Content-type', 'image/png')
ctx.body = result
}

前端接受到这个 image 后,再利用 createObjectURL, revokeObjectURL 立即下载图片即可。下面展示的是前端这一部分处理的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function fetchApi (url, data) {
return fetch(url, {
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
},
cache: 'no-cache',
method: 'POST',
mode: 'cors'
})
}

function getImageData(url, data) {
return fetchApi(url, data)
.then(response => response.blob())
}

function getImage(data) {
getImageData('/downloadImage', data)
.then(response => {
let a = document.createElement('a')
let url = window.URL.createObjectURL(response)
let filename = `${data.scene}-${+new Date()}.png`

a.href = url
a.download = filename

a.click()
window.URL.revokeObjectURL(url)
})
}

我用的浏览器原生的 Fetch Api, 因为不想在前端还引入别的库。注意 fetchApi 返回不同的内容,要用不同的内容处理。比如上面的图片是用的 response.blob() 处理,如果是返回的json, 需要用 response.json() 处理。


获取 AccessToken

注意如果没有自己的获取 AccessToken 的中控服务器,一定要缓存 accessToken, accessToken 有过期时间,并且一天有调用上限。部门内有专门的中控服务器,但是我还是自己试了下自己做缓存的流程,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
 const rp = require('request-promise')
const cachePath = './cache/token.txt'

/**
* 获得 access_token
* 若缓存文件存在,则从缓存中读取
* 若缓存文件不存在,调用 update 接口更新
* @returns {Promise<*>}
*/
async getAccessToken () {
let token = ''

if (fs.existsSync(cachePath)) {
token = JSON.parse(fs.readFileSync(cachePath))
}

if (!token || token.timeout < Date.now()) {
let res = JSON.parse(await this.updateAccessToken())

res.timeout = Date.now() + (res.expires_in - 200) * 1000
fs.writeFileSync(cachePath, JSON.stringify(res))

return res
} else {
return token
}
}

/**
* 更新微信 Access_token
* 此处有每天调用 2000 次限制
* 2h 即失效
* @returns {Promise<*>}
*/
async updateAccessToken () {
const { appid, appkey } = serverConfig
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${appkey}`

return rp.get(url)
}

相当于每次调用后,要重新写入文件过期时间,如果过期时间到了,那么重新去更新。之所以拿 re.expires_in - 200, 是为了留点 buffer, 这个值当然可以自己去设置。


生成二维码

这里的 logo, 因为我们公众号只有一个 logo, 故直接是从文件中读取了。以后考虑把这个放在 github 的话,可能会支持用户可以自己上传 Logo。

还有一个地方要注意,就是计算 logo 的位置。直接除以二维码的高宽一半是不行的,还要减去 logo 的一半哦。

我这边在返回给前端下载的时候,也 save 到本地了,担心产品丢失信息。save 的时候,我以场景值和时间为名字,会方便日后好找一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
  /**
* 添加 logo
* @param imageStream
* @returns {Promise<*>}
*/
async getLogo (imageStream) {
const imgBuffer = await streamToPromise(imageStream)

const { width: imgWidth, height: imgHeight } = imgSize(imgBuffer)

const logoWrapper = images("./static/assets/logo.png").size(imgWidth / 4)

const x = (imgWidth - logoWrapper.width()) / 2
const y = (imgHeight - logoWrapper.height()) / 2

return await this.drawImage(imgBuffer, logoWrapper, x, y)
}

/**
* 画图
* @param imgBuffer
* @param logoWrapper
* @param x
* @param y
* @returns {Promise<any>}
*/
async drawImage (imgBuffer, logoWrapper, x, y) {
return new Promise((resolve, reject) => {
const image = images(imgBuffer)
.draw(logoWrapper, x, y)
.save(`./static/image/${this.scene}-${+new Date()}.png`, {
quality : 50
})

if (!image) {
reject()
}

resolve(image)
})
}


入口文件

入口文件跟平常的入口文件基本上没有什么大区别。比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const Koa = require('koa')
const path = require('path')
const Router = require('koa-router')
const StaticServer = require('koa-static')

const config = require('./config')
const routeMiddleware = require('./middleware/route')

const App = new Koa()
const router = new Router()

const staticPath = './static'

App.use(StaticServer(path.join(__dirname, staticPath)))

routeMiddleware(App, router)

App.use(router.routes()).use(router.allowedMethods())

App.listen(config.port, () => {
console.log(`App listening on port ${config.port}`)
})


总结

想当一个优秀的 Nodejs 工程师,但是发现自己还远远不够,比如 fs 的函数不熟。比如对 Buffer, Stream 理解的不够深入。还是写的太少了。不过总算是按时完成了。解放了劳动力。🚫

下一步打算优化下代码,看能不能支持 upload logo, 支持传 appid, appkey, 这样别人也可以用了。还有很多基础工作没有做,比如 async, await 的错误处理,比如 logger, pm2 等。这个会慢慢完善,基础功能已 OK。 📚🏠