centos + nodejs + egg2.x 开发微信分享功能

方航
2023-12-01

前言

近期把自用的微信公众号微信分享模块从 php 修改为 nodejs 的版本,虽然这是一个很小的功能,但仍然选择了 egg 框架,也算是为未来继续开发公众号,做点扩展的准备。

本文章仅为项目介绍,不涉及 egg 的原理,请不要问我为啥不直接用koa。

一、 egg本地环境搭建

1. egg简介

koa框架:基于 Node.js 平台的新的 web 框架,由 Express 幕后的原班人马打造,它与Express使用同一套http基础库。最新的koa2,是基于ES7开发的,完美支持了promise及async。

egg框架:egg2.x 以Koa2.x 作为其基础框架,兼容Koa 2.x 的中间件,最低支持 Node.js 8

通过一张图来描述 egg2.x :

更多的这里就不说了,有兴趣的童鞋请看 eggjs.org/zh-cn/intro…

2. 安装

npm i egg --save
npm i egg-bin --save-dev
复制代码

3. 配置启动scripts

{
  "name": "egg-example",
  "scripts": {
    "dev": "egg-bin dev"
  }
}
复制代码

二、本地目录结构

创建本地目录如下:

node
    ├── package.json
    ├── app
    │   ├── extend                                        // 扩展
    │   |   ├── helper.js
    │   ├── service                                       // 服务
    |   ├── controller                                    // 控制器
    |   ├── public                                        // 静态资源路径
    │   ├── middleware                                    // 中间件
    │   └── router.js                                    // 路由
    └── config
        ├── config.default.js                            // 配置
        └── plugin.js                                    // 插件
复制代码

以上仅是本案例中的结构,完整结构,参考官方:eggjs.org/zh-cn/basic…

三 、配置域名及nginx反向代理

egg 服务端默认采用的是 7001 端口,因为我们将二级域名: share.xxx.com 解析到 7001 上,实现域名直接访问 egg 接口。

解析二级域名

server {
    listen 80;
    server_name  share.xxx.com;
    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
        proxy_pass      http://127.0.0.1:7001;
    }
}
复制代码

nginx 反向代理

服务器重新启动 nginx 后,即可通过 share.xxx.com 访问到我们的 7001 端口了!

四、微信分享功能逻辑

微信分享其实是一个比较简单的功能,难点也就是了解微信公众号的token如何转换成签名,这里简单画了一个图:

权限,指的是安全域名,需要到公众号后台进行设置(如下图),并填写你的分享链接域名。

填写域名时,需要将公众号给的验证文件放到根目录,在你点击保存时,公众号服务器将会去请求本文件,验证该域名是否有效。

具体的获取 token 及 ticket 接口及代码,会在下面详细讲解。

五、代码开发

1. 验证接口

安全域名,需要在根目录放一个文本文件,公众号会尝试打开该文件,并验证其中的key。由于 egg 无法直接访问根目录文件,因此使用路由来实现验证接口

// 路由
router.get('/MP_verify_ysZJMVdQxMoU8v35.txt', controller.check.index);

// 验证
class CheckController extends Controller {
    async index() {
        let cache = await this.ctx.helper.readFile(path.join(this.config.baseDir, 'app/MP_verify_ysZJMVdQxMoU8v35.txt'));
        this.ctx.body = cache;
    }
}
复制代码

2. getTicket接口

egg 针对 csrf 安全做了以下几种处理:

  • Synchronizer Tokens
  • Double Cookie Defense
  • Custom Header

在 CSRF 默认配置下,token 会被设置在 Cookie 中,在 AJAX 请求的时候,可以从 Cookie 中取到 token,放置到 query、body 或者 header 中发送给服务端。

以 jquery 为例, 在 beforeSend 中,增加 header 项 x-csrf-token:

// 请求签名
var token = getCookie('csrfToken');
if(token){
    var url = location.href.split('#')[0];
    var host = location.origin;
    $.ajax({
        url: host + "/getTicket",
        type: 'post',
        data: {
            url: encodeURIComponent(url)
        },
        beforeSend: function (request) {
            request.setRequestHeader("x-csrf-token", token);
        },
        success: function (res) {
            if(res.code === 0){
                wx.config({
                    debug: true,
                    appId: res.data.appId,
                    timestamp: res.data.timestamp,
                    nonceStr: res.data.nonceStr,
                    signature: res.data.signature,
                    jsApiList: [
                        'updateTimelineShareData',
                        'updateAppMessageShareData'
                    ]
                }); 
                wx.ready(function () {
                    var shareData = {
                        title: '我的分享',
                        desc: '我的文字介绍,详细的',
                        link: host,
                        imgUrl: host + "/public/images/icon.jpg"
                    };
                    wx.updateTimelineShareData(shareData);
                    wx.updateAppMessageShareData(shareData);
                });
                wx.error(function (res) {
                    console.log(res.errMsg);
                });
            }else{
                console.log(res);
            }
            
        }
    });
}else{
    alert('invalid csrf token');
}
复制代码

3. 微信获取token

async getToken(ctx, config){
    let timestamp = new Date().valueOf();
    let cache = await this.ctx.service.fileService.read('token');
    let result = cache;

    // 缓存失效
    if (!cache || cache.expires_in < timestamp || cache.app_id !== config.wx.appId) {
        result = await ctx.curl(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${config.wx.appId}&secret=${config.wx.secret}`, {
            dataType: 'json'
        });
        if (this.ctx.helper.checkResponse(result)) {
            result = {
                access_token: result.data.access_token,
                expires_in: timestamp + result.data.expires_in * 1000,
                app_id: config.wx.appId
            };
            this.ctx.service.fileService.write('token', result);               
        } else {
            this.ctx.service.fileService.write('token', '');
            this.ctx.logger.error(new Error(`${timestamp}--wxconfig: ${JSON.stringify(config.wx)}--tokenResult: ${JSON.stringify(result)}`));
            result = null;
        }
    }

    return result;
}
复制代码

4. 微信获取ticket

async getTicket(ctx, config, res){
    let timestamp = new Date().valueOf();
    let cache = await this.ctx.service.fileService.read('ticket');
    let result = cache;

    // 缓存失效
    if (!cache || cache.expires_in < timestamp || cache.app_id !== config.wx.appId) {
        result = await ctx.curl(`https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${res.access_token}&type=jsapi`, {
            dataType: 'json'
        });
        if (this.ctx.helper.checkResponse(result)) {
            result = {
                ticket: result.data.ticket,
                expires_in: timestamp + result.data.expires_in * 1000,
                app_id: config.wx.appId
            };
            this.ctx.service.fileService.write('ticket', result); 
        } else {
            this.ctx.service.fileService.write('ticket', '');
            this.ctx.logger.error(new Error(`${timestamp}--wxconfig: ${JSON.stringify(config.wx)}--jsapiResult: ${JSON.stringify(jsapiResult)}`));
            result = null;
        }
    }

    return result;
}
复制代码

5. 缓存策略

oken 及 ticket 的有效期均为 7200 秒,且有一定的请求频率限制,因此推荐在服务器本地缓存这两个串,开发者可以自行选择存储在本地、数据库、全局中。

以本项目为例,这里偷懒了下,直接存在本地txt中,别问我为啥用文件存储 ^_^,我不会告诉你是懒的装数据库。

async read(type) {
    let src = type === 'token' ? this.tokenFile : this.ticketFile;
    let data = await this.ctx.helper.readFile(src);
    data = JSON.parse(data);
    return data;
}

async write(type, data) {
    let src = type === 'token' ? this.tokenFile : this.ticketFile;
    await this.ctx.helper.writeFile(src, JSON.stringify(data));
}
复制代码

6. 生成签名

签名生成规则如下:

参与签名的字段包括noncestr(随机字符串), 有效的jsapi_ticket,timestamp(时间戳),url(当前网页的URL,不包含#及其后面部分) 。对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL拼接成字符串string1,这里需要注意的是所有参数名均为小写字符。对string1作sha1加密,字段名和字段值都采用原始值,不进行URL 转义。

const uuidv1 = require('uuid/v1');
const noncestr = uuidv1();
const timestamp = Math.round(new Date().valueOf() / 1000);
const string1 = `jsapi_ticket=${jsapi_ticket}&noncestr=${noncestr}&timestamp=${timestamp}&url=${url}`;
const crypto = require('crypto');
const hash = crypto.createHash('sha1');
hash.update(string1);
const signature = hash.digest('hex');
return {
    nonceStr: noncestr,
    timestamp,
    signature,
    appId: appId,
    jsapi_ticket,
    url,
    string1
};
复制代码

六、本地调试

这里推荐使用 vs code 来调试服务端代码,需要简单进行配置,参考 egg 官方文档: eggjs.org/zh-cn/core/…

七、部署项目

在正式部署前,开发者根据需要自行配置中间件、模板、插件、启动配置项等。

1. 启动

egg 提供了 egg-scripts 来支持线上环境的启停。

npm i egg-scripts --save
复制代码

添加 npm scripts

{
  "scripts": {
    "start": "egg-scripts start --daemon --title=egg-server-showcase",
    "stop": "egg-scripts stop"
  }
}
复制代码

egg 默认会开启 cpu 数量的进程,性能方面还是不错的。

2. 停止

egg-scripts stop [--title=egg-server]
复制代码

3. 保活

框架内置了 egg-cluster 来启动 Master 进程,因此不需要做额外配置即可。如有特殊需求,框架也支持使用 pm2 来做管理:

八、性能监控

我们使用 egg 官方推荐的 Node.js 性能平台(alinode)

1. 安装 Runtime

AliNode Runtime 可以直接替换掉 Node.js Runtime

// 安装版本管理工具 tnvm,安装过程出错参考:https://github.com/aliyun-node/tnvm
wget -O- https://raw.githubusercontent.com/aliyun-node/tnvm/master/install.sh | bash
source ~/.bashrc

// https://help.aliyun.com/knowledge_detail/60811.html,这里有node版本对应的alinode版本
tnvm install alinode-v4.2.2 # 安装需要的版本
tnvm use alinode-v4.2.2 # 使用需要的版本

// 由于egg官方封装了egg-alinode 来快速接入,无需安装 agenthub
复制代码

2. 安装配置 egg-alinode

npm i egg-alinode --save
复制代码

开启插件:

// config/plugin.js
exports.alinode = {
  enable: true,
  package: 'egg-alinode',
};
复制代码

配置:

// config/config.default.js
exports.alinode = {
  // 从 `Node.js 性能平台` 获取对应的接入参数
  appid: '<YOUR_APPID>',
  secret: '<YOUR_SECRET>',
};
复制代码

3. 启动应用

阿里官方使用的开启方式,是在命令行加入以下代码:

ENABLE_NODE_LOG=YES node demo.js
复制代码

但在egg中,有自己的启动方式,仍然是:

npm start
复制代码

官方解释是:

成功启动后,访问几次你的接口,稍等一会,即可在控制台看到数据了。

控制台地址: node.console.aliyun.com

常见错误

1. 安装 tnvm 遇到报错

怀疑是 1.7.1 git 版本太老造成,升级 git...

// 下载 git 2.21.1 版本
wget https://github.com/git/git/archive/v2.21.1.tar.gz
tar -zxvf v2.21.1.tar.gz

// 安装依赖
yum install curl-devel expat-devel gettext-devel openssl-devel zlib-devel gcc perl-ExtUtils-MakeMaker

// 删除老版本git
yum remove git

// 进入解压后的文件夹
cd git-2.21.1

// 编译
make prefix=/usr/local/git all
make prefix=/usr/local/git install

// 环境变量
echo "export PATH=$PATH:/usr/local/git/bin" >> /etc/profile

// 让环境变量生效
source /etc/profile
复制代码

2. undefined reference to `libiconv'

cd /usr/local/src

// 下载新版的libiconv
wget http://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.15.tar.gz
tar -zxvf libiconv-1.15.tar.gz

// 安装
cd libiconv-1.15
./configure --prefix=/usr/local/libiconv && make && make install

// 创建链接
ln -s /usr/local/lib/libiconv.so /usr/lib
ln -s /usr/local/lib/libiconv.so.2 /usr/lib
复制代码

然后重复上面安装git过程的12行之后即可

3. git clone 报错 SSL connect error

GitHub 前几天开始不支持老的加密方式,升级到 CentOS 6.8 或者单独升级SSH,以下命令2选1

yum update -y
yum update openssh
复制代码

至此,整篇文章全部结束,这里的代码部分也有参照一些别的教程,所以总体来说,并不是难度多大的东西,大家互相学习互相进步吧。

 类似资料: