项目总结

最近一个多月加了很多班,做了一个比较紧急的项目 Nodejs + Vue全家桶,感觉自己收获多多。今天已经发布了现网版本,趁热总结和梳理下。


写在前面

  1. Nodejs koa2, async await用的很舒适。
  2. Vue 全家桶,vue webpack vuex可快速开发。
  3. 前后端分离,Nodejs 端纯做 Api 层。
  4. pm2 进程管理工具。又熟悉了很多命令和踩了一些坑。
  5. log4js日志打印工具。了解了部门的日志规范,用于查询问题。
  6. 数据库用的 sequelize
  7. 登录用的 koa-generic-session
  8. 了解了 ToB 的一些概念及思想。

以后就抛弃 PHP 这个世界上最好的语言吧。以下主要是自己对自己问题的一些梳理。并不涉及到任何的真实项目代码⌨️🏠。


获取用户的IP信息

在很多种情况下,我们Nodejs层需要把用户的 ip 传给后台的 cgi, cgi 会根据这个 ip 做一些策略,如风控,营销等等。这就涉及到 ip 怎么取的概念,你可能会接触到这几个 ip:

  1. ctx.request.ip
  2. ctx.headers[‘x-forwarded-for’]
  3. ctx.headers[‘x-real-ip’]

先来普及下 x-forwarded-forx-real-ip

x-forwarded-for的格式一般为:X-Forwarded-For: client1, proxy1, proxy2 如:X-Forwarded-For: 1.1.1.1, 2.2.2.2, 3.3.3.3

最左边(client1)是最原始客户端的IP地址, 代理服务器每成功收到一个请求,就把请求来源IP地址添加到右边。 在上面这个例子中,这个请求成功通过了三台代理服务器:proxy1, proxy2 及 proxy3。请求由client1发出,到达了proxy3(proxy3可能是请求的终点)。请求刚从client1中发出时,XFF是空的,请求被发往proxy1;通过proxy1的时候,client1被添加到XFF中,之后请求被发往proxy2;通过proxy2的时候,proxy1被添加到XFF中,之后请求被发往proxy3;通过proxy3时,proxy2被添加到XFF中,之后请求的的去向不明,如果proxy3不是请求终点,请求会被继续转发。鉴于伪造这一字段非常容易,应该谨慎使用X-Forwarded-For字段。正常情况下XFF中最后一个IP地址是最后一个代理服务器的IP地址, 这通常是一个比较可靠的信息来源。 –维基百科

x-real-ip没有相关标准,但是在反向代理和正向代理下,它的值可能不同。正向代理时,记录的是客户端的真实ip。 反向代理时,记录的是最后一级的代理ip。

所以:x-real-ipx-forwarded-for这两个 ip 就是很普通的请求头,它们是可以被篡改的。比如:

1
curl http://www.my.com:8089 -H 'X-Forwarded-For: 1.1.1.1' -H 'X-Real-IP: 2.2.2.2'

那么这两个值都容易被篡改,不可信,我们要拿到用户真实的 ip 怎么办呢?

一般在建立 TCP 连接时,会产生真实的 IP, 叫做 Remote Address。因为是建立在 TCP 中,所以这个 ip 不能被篡改。一旦篡改,握手不成功,那么后面自然就没有了。所以如果我们要取真实的 ip,应该从这个字段里取。

实际上应该取那个值,跟你 nginx 的配置有关系。比如一般配置 nginx 反向代理的时候可能这样配置:

1
2
3
4
5
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

// 若 XFF 有值,则 $proxy_add_x_forwarded_for = $XFF, $remote_addr ($XFF$remote_addr逗号分割,$remote_addr在其后)
// 若 XFF 没有值,则 $proxy_add_x_forwarded_for = $remote_addr

像这样赋值后,如果你是通过 nginx 反向代理来的,nginx 会把用户实际 ip ($remote_addr)赋值给 X-Real-IP,这时候它也是真实的用户IP了。而 XFF 也是有了用户的真实IP。

通过这样的 nginx 配置。整个内容就无法被篡改和构造,我们就可以从 X-Real-IPXFF的最后一截 获得用户真实的 IP 了。

所以我最后是怎么传什么字段给后台的呢?我最后传的是 x-forwarded-ip, 拿到这一系列的 ip list 后,后台会去通过网段校验,判断出哪个是外网 ip, 然后把这个 ip 当成是用户真实的 ip。 cgi 还是很严谨的。

最开始谈到的 ctx.request.ip 是最后一次的 ip, 所以有可能是代理机器的 ip。

遇到一篇好文章:x-forwarded-for-header-in-http

一个实例,判断用户IP是否正常:微信H5支付需判断下单IP和支付IP是否一致


关于 PM2 的 cluster 模式

pm2 是很好的进程管理器,有自动重启等功能,并且还自带负载均衡。我这里就不讲具体的 cluster 模式和 pm2 了。就只讲下我遇到的坑。

cluster 模式下,log4js 日志,无法正常打印。这个已经有相关的 issue,地址在这里。大概的意思是 cluster 模式下,并没有 master 进程,而是只有多个 worker 子进程。而 log4js 只在 master 进程下,才会进行打印日志的操作。之所以这样是因为多个 worker 操作同一个日志文件(一般我们日志文件只有一套配置)可能会导致有错乱等问题。有解决问题的方法,都在那个 issue 里,但是我总觉得不太好。

另一个问题是,在 cluster 模式下,利用 restart 命令无法完全重启进程,fork 模式下就不会,猜测是 cluster 模式有多个 worker, 重启是否需要 restart 指定是哪个子进程才可以?还发现,如果只有一个子进程,不指定也会不能重启。我明天会去验证下。

遇到了一篇好文章: using-pm2-to-manage-cluster


登录态验证

我们利用了koa session, cookie session 这种形式。开始的时候我把这里想复杂了,想要结合微信的登录态。但实际上纯 session cookie 这种反而更好。

大概流程是:用户登录网站,调用微信登录,拉起授权,授权的地址调用 Nodejs 后台的一个自己封装的接口。在这个接口里获得用户的 openid ,校验用户的权限并且设置 ctx.session.sessionId,然后返回到前端,前端调用后台的接口,通过已经种下的 sessionId 即可判断出用户的权限,然后再返回给前端。前端再针对身份到对应的路由。

那么你肯定会问,为什么不在直接设置 sessionId 的时候把用户信息返回了呢?这样也可以的,那么相当于你微信授权的回调地址要写一个前端的页面, 然后在这个页面中再来判断用户根据身份到哪个页面。这个前端页面你需要单独的也去维护。这样也是可以的。你也许会问,我不用单独再写个页面,用首页就好了。但是用首页会存在一个问题,就是最终由于授权,你的首页就带上了 code, 还要用 replaceState 什么的去掉,我觉得很不整洁。但是也是可以的。这两种方式本质上都可以。只不过第一种偷懒了,直接redirect到首页,首页再发一次请求。

我用的登录中间件是: koa-generic-sessionapp.use下面这个中间件即可。

1
2
3
4
5
6
7
8
const sequelizeObj = connectDb.db[dbName]

sessionConfig.options.store = new SequelizeStore(sequelizeObj, {
sync: false,
tableName: 't_session'
})

return session(sessionConfig.options)

存储并没有存在内存中,而是存在了数据库里,这里用的koa-generic-session-sequelize不放在内存里的原因是有多台机器,存在内存中不能共享应该会有问题。

设置 session cookie 的时候,在前端 cookie 里,就会有对应的值,然后每次请求页面,都会把这个 cookie 带上,去数据库里找对应的 session 是否失效。如果没有失效,认为ok,那么就可以跳过验证的过称了。

还可以加上 app.keys ,然后再对应的 options 里加上 signkeys。

1
2
3
4
app.keys = ['im a newer secret', 'i like turtle'];
app.keys = new KeyGrip(['im a newer secret', 'i like turtle'], 'sha256');

ctx.cookies.set('name', 'tobi', { signed: true });

这样,你的 cookie 的值,就会被加密了。前端看到的 cookie 可能是一个加密过的串。然后你到服务器端 get cookie 的时候,它会自动帮你解密。得到对应的值去和数据库里的session进行比对。

那如果前端的微信登录态过期了怎么办呢?比较简单,前端发现登录态失败,比如登录态失败的返回码是1111,那么1111时重新拉起微信登录即可。


日志管理

日志管理对于一个大型的项目来说,尤其是涉及到支付的非常重要,他用于帮你查看用户做了什么事情,或者系统出了什么❌。非常的重要。

日志我这里用的 log4js, 分为了四种 category, 分别用来记录 db 操作,访问日志,开发者 debug 信息,cgi 信息日志,错误日志。这些需要放在不同的文件里,便于开发者找问题。

你可以设置不同的 categories, 每个 categories 里设置 level 和不同的 appenders。 每一个 appenders 都可以设置不同 filename 等等。

那么这个 db 的操作日志,怎么打印呢? sequelize 建立跟 db 链接时,可以传入 logging 这个 option, 在这个 logging 里设置 sequelize 的打印方法。如下面我就是把打印db的操作日志通过 log4js 打入了日志中:

1
2
3
db.options.logging = msg => {
dbLogger.debug(msg)
}

koa2 的错误处理

这个错误处理的方法,网上到处都是的了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 错误处理中间件
module.exports = async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.response.status = err.statusCode || err.status || 500

ctx.response.body = {
message: '系统繁忙,请稍后再试!'
}

ctx.app.emit('error', err, ctx)
}
}

// app.js 中接收 error 事件,并且上报到错误日志中
app.on('error', (err, ctx) => {
ctx.errorLogger(`name=${err.name}&msg=${encodeURIComponent(err.message)}&stack=${encodeURIComponent(err.stack)}`)
})

这里注意如果你直接打印的是 err 对象,一般只有它的 msg 会被打印出来,如果你想要更多信息,一定要把 stack 打出来。这个我疏忽了,同事帮我提出来了,很棒很优秀。

我去网上查了下,这个错误处理其实是 koa2 里的默认方式,只不过我们又自己去重写了,他本身的是这样的:

可以看下这篇文档:error-handling.md#default-error-handler

他里面推荐了一种写法:

1
2
3
4
5
6
7
8
9
10
11
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// will only respond with JSON
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
message: err.message
};
}
})

我觉得这种写法并不是很好,因为如果你这个是数据库的某些地方出错了,可能会在 err.message 里暴露一些你机器 ip 及一些其他的信息,这个是不安全的。统一给用户一个回复,然后打到系统错误日志中,是比较好的做法。

这里途中看到了一篇文章: 大概讲的是koa错误处理的写法

注意错误处理最好被当做最前面的一个中间件。


总结

先写这么多,其实还有很多很小的细节。下次再整理整理,当然上面只是我自己的理解,可能也有我理解错了的。我会经常看它,然后看自己是否后面又新的认识来推翻现在的想法。这个项目是我跟着组里另外一个很多年经验的老司机一起做的,在他身上,我学习到了严谨的态度。有很多地方他都比我有经验,也给我指出了一些问题。所以说公司和老板给他那么多工资是应该的啊。优秀哈哈。👍📚

🌲希望下次有机会试下 Vue SSR。