Fork me on GitHub
余鸢

微信公众号开发(二):构建公众号服务器

申请公众号

在构建项目之前,我需要去注册申请一个微信公众号,点击立即注册,选择服务号,只需要按照流程步骤来走即可,这里不详细解说了。

初始化项目

通过命令初始化项目

1
vue init nuxt/koa nuxt-ka

项目创建成功后在根目录下创建ecosystem.json,表示最终发布上线的发布脚本。

server目录下index.js为入口文件,定义host和port变量,通过start()函数开启后台服务。创建nuxt实例new Nuxt(config),判断如果当前是dev开发环境下实时编译整个项目await nuxt.build(),然后通过nuxt.render(ctx.req, ctx.res)返回页面结果,最后通过app.listen()启动服务器。

明白了index.js的用处,接着对代码做一些修改。

server/index.js

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
42
// Import and Set Nuxt.js options
let config = require('../nuxt.config.js')
config.dev = !(process.env === 'production')
const host = process.env.HOST || '127.0.0.1'
const port = process.env.PORT || 3000
class Server {
constructor () {
this.app = new Koa()
this.useMiddleWares(this.app)(MIDDLEWARES)
}
// 中间件
useMiddleWares (app) {
}
async start () {
// Instantiate nuxt.js
const nuxt = new Nuxt(config)
// Build in development
if (config.dev) {
const builder = new Builder(nuxt)
await builder.build()
}
this.app.use(async (ctx, next) => {
await next()
ctx.status = 200 // koa defaults to 404 when it sees that status is unset
return new Promise((resolve, reject) => {
ctx.res.on('close', resolve)
ctx.res.on('finish', resolve)
nuxt.render(ctx.req, ctx.res, promise => {
// nuxt.render passes a rejected promise into callback on error.
promise.then(resolve).catch(reject)
})
})
})
this.app.listen(port, host)
console.log('Server listening on ' + host + ':' + port) // eslint-disable-line no-console
}
}
const app = new Server()
app.start()

中间件

微信公众号是需要我们的服务器和微信服务器有基于消息的http请求交互,在项目中要有一层用于接管来自微信服务器推送消息,这些推送消息的功能统一放到中间件里,这里增加个userMiddleWare()中间件,中间件不止有一个,这就需要对于多个中间件做统一处理。

为了减少重复添加代码使用map来对中间件进行管理,这里就要借助工具第三方库ramda对中间件做管理。首先声明一个数组MIDDLEWARES来配置中间件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import R from 'ramda'
...
// 路径
const r = path => resolve(__dirname, path)
const MIDDLEWARES = ['router']
class Server {
constructor () {
this.app = new Koa()
this.useMiddleWares(this.app)(MIDDLEWARES)
}
// 中间件
useMiddleWares (app) {
return R.map(R.compose(
R.map(i => i(app)),
require,
i => `${r('./middlewares')}/${i}`
))
}
...
}

通过R.map()解析数组中的每一个值,然后交给可以生成绝对路径的函数i => ${r('./middlewares')}/${i},再把路径交给require,通过require加载这个模块,再对它传入i,让每个中间件都能拿到app对象同时进行初始化工作。

增加router

server目录下创建middleWares添加router.js文件

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
import Router from 'koa-router'
import config from '../config'
import sha1 from 'sha1'
export const router = app => {
const router = new Router()
router.get('/wx', (ctx, next) => {
const token = config.wechat.token
// 参数
const {
signature,
nonce,
timestamp,
echostr
} = ctx.query
// 对参数进行排序
const str = [token, timestamp, nonce].sort().join('')
// 加密
const sha = sha1(str)
if (sha === signature) {
ctx.body = echostr
} else {
ctx.body = 'Failed'
}
})
app.use(router.routes())
app.use(router.allowedMethods())
}

通过router.get()拿到请求,/wx表示对服务器配置的路径。接收微信服务器推送的get请求时可以获取到参数,接着对参数进行排序加密。

安装所需要的依赖包

1
2
3
4
yarn add koa-router
yarn add sha1
yarn add babel-preset-stage-3@6.24.1 -D
yarn add babel-preset-latest-node@0.2.2 -D

再回顾一下入口文件server/index.js的执行流程:

在服务器里通过ramda函数式库进行管理中间件MIDDLEWARES,通过new Server()开启服务,启动服务后如果能接收到来自微信服务器的请求,router就能通过/wx的路径规则监听到推送的请求,根据请求获取到参数,然后根据微信的加密规则对这些参数进行排序加密。根据加密的值是否是符合的,对微信服务器进行响应。

启动

在项目根目录下新建start.js,通过start.js间接的调用server/index.js启动整个后台服务。

start.js

1
2
3
4
5
6
7
8
9
require('babel-core/register')({
'presets': [
'stage-3',
'latest-node'
]
})
require('babel-polyfill')
require('./server')

配置启动命令,修改package.json

1
2
3
4
"scripts": {
"dev": "nodemon -w ./server -w ./start.js --exec node ./start.js",
...
},

启动项目

1
npm run dev

同时打开配置好的服务器

1
sunny.exe clientid 隧道id

修改url和token,点击提交。

wx23

服务器成功相应,说明访问成功!如图:

wx22

微信消息

用户在公众号的窗口上可能会录语音、上传图片、输入文案等等,这些交互叫做事件。这些事件触发数据的变化会上报给微信服务器,微信服务器收到这些事件后会按照它们的类型分为相应的标准的xml数据来通知我们,通知的方式就是在微信公众号后台配置的url,也就是已经接入的地址,所有用户交互的数据都会以这个地址来流到我们的服务器里。

如果是本地开发的话会通过Ngrok把本地某个端口的服务代理出去,如果是生产环境下的话,不需要代理工具,通过url的数据流到我们的后台服务器。数据进来之后会经过koa路由的中间件,然后根据get或post请求做响应的解析,如果是post请求的话就把整个数据包解析出来,根据解析出来的数据会进行回复策略的制定,这个回复策略可能会查数据库,可能会读本地的json文件。经过回复策略的处理后,整个回复的内容就已经有了,内容在回复给微信服务器之前需要去同步全局的票据,也就是access_token。

access_token

access_token就相当于是一把钥匙,只有给出这把钥匙微信服务器才会认同发请求的对方是合法的一方,如果access_token不合法或者过期或是不存在,那么相应的回复也不会生效。拿到access_token之后,把内容根据回复策略进行拼接生成一份标准的回复数据,这个回复数据需要套到xml模板里去,最后在把xml模板数据交给微信服务器,下发给触发事件的用户,用户就可以看到之前某个交互带来的结果,这样就形成完整闭环。

闭环里有几个关键点:

1、koa路由中间件来针对微信的请求做拦截和处理。

2、对get或post请求的区分。如果是get请求往往是第一次捕获认证的身份。如果是post请求就意味是一个事件通知或者是一个数据,需要把这个数据解析出来,这里就需要解析的中间件。

3、回复策略。回复策略会产生查询的异步操作,这里也需要中间件。

4、对access_token的处理,需要对它进行存储。

增加数据库中间件

把token存储到数据库里。数据库选择使用mongoose,安装mongoose。

1
yan add mongoose

拿到实例app,设置mongoose连接中断时、出错时、打开时做的处理,连接中断时重新连接数据库,出错时打印错误日志。成功连接数据库后对Scheam进行初始化数据,读取所有的models对它们进行过滤,只筛选出后缀为.js的文件。

server/middlewares/database.js

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
import mongoose from 'mongoose'
import config from '../config'
import {resolve} from 'path'
import fs from 'fs'
const models = resolve(__dirname, '../database/schema')
// 同步读入模型文件
fs.readdirSync(models)
.filter(file => ~file.search(/^[^\.].*js$/))
.forEach(file => require(resolve(models, file)))
export const database = app => {
mongoose.set('debug', true)
mongoose.connect(config.db)
// 连接中断
mongoose.connection.on('disconnected', () => {
mongoose.connect(config.db)
})
// 出错
mongoose.connection.on('error', err => {
console.error(err)
})
// 打开
mongoose.connection.on('open', async => {
console.log('Connected to MongoDB ', config.db)
})
}

新建token.js存放access_token。声明TokenSchema传入token字段,保存每条数据之前先经过中间件的处理,判断是否是新增数据,记录时间。

server/database/schema/token.js

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
const mongoose = require('mongoose')
const Schema = mongoose.Schema
const TokenSchema = new mongoose.Schema({
name: String,
access_token: String,
expires_in: Number,
meta: {
createdAt: {
type: Date,
default: Date.now()
},
upatedAt: {
type: Date,
default: Date.now()
}
}
})
// 保存每条数据之前先经过中间件的处理,判断是否是新增数据
TokenSchema.pre('save', (next) => {
if (this.isNew) {
this.meta.createdAt = this.meta.upatedAt = Date.now()
} else {
this.meta.upatedAt = Date.now()
}
next()
})

给TokenSchema增加静态方法,从model调用模型。通过mongoose.model()拿到token数据模型,创建数据模型实例new Token(),保存token

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
TokenSchema.static = {
// 获取token
async getAccessToken () {
const token = await this.findOne({
name: 'access_token'
}).exec()
return token
},
// 保存token
async saveAccessToken (data) {
let token = await this.findOne({
name: 'access_token'
}).exec()
if (token) {
token.token = data.access_token
token.expires_in = data.expires_in
} else {
token = new token({
name: 'access_token',
token: data.access_token,
expires_in: data.expires_in
})
}
await token.save()
return data
}
}
const Token = mongoose.model('Token', TokenSchema)

在server/index.js修改MIDDLEWARES变量

1
const MIDDLEWARES = ['database', 'router']

修改后MIDDLEWARES里面的database就被关联到middlewares/database.js,从而加载schema。