前段时间做了一个 nodejs 应用,项目架构是 前端 vue 单页应用,后端 nodejs
其实有考虑 ssr,但是因开发时间比较紧张,就没能使用。
下面是开发过程中的一些经验以及遇到的一些问题。
一、技术架构
具体项目技术栈如下:
client端: vue 全家桶、history-router
server端: koa、koa-router、redis+sentinel、msyql、java (java后端组同学开发)
二、项目目录
1 | client/ # 所有的前端文件 |
三、技术要点
promise、async await
promise、async、await都属于javascript基础,这里略过。
client 端的请求
请求类型大概分为如下几类,以及各个类别对应的 koa 处理中间件模块
1.页面请求 —— history-router
2.静态资源请求 —— koa-static
3.favicon请求 —— koa-favicon
4.接口请求 —— koa-router
NODEJS 请求过程
koa 中间件、node端路由
中间件:中间件在请求和响应的过程中给我们一个修改数据的机会
中间件的功能包括:
1.执行任何代码。
2.修改请求和响应对象。
3.终结请求 - 响应循环。
4.调用堆栈中的下一个中间件
中间件是koa的核心,中间件return一个中间件函数,最好是用一个函数给封装起来,以便于传参和可扩展性。
本项目几乎所有路由处理都是通过中间件完成的。
中间件操作分为同步操作和异步操作。
同步操作很简单,处理完事务之后直接 await next() 到下一个中间件即可。
1 | function middleFunction(param1, param2) { |
异步中间件,也很好理解,就是在中间件内部进行处理的是一个异步流程。
我们可以借助 async 和 await 来处理异步事务。
1 | function middleFunction(param1, param2) { |
koa 中中间件是最核心的操作,因此往往会有很多中间件,中间件多意味着管理上需要花费更多的精力。
因此,koa 也提供了一些很方便的管理工具,如:用 koa-compose 组合中间件
1 | const compose = require('koa-compose') |
多个中间件如何执行?执行顺序如何?
koa 中间件执行过程是一层一层的执行的,由外而内,再由内向外。
网上流传着很广泛的“洋葱模型”很好的诠释了这顺序,如下图所示:
等同于下面的这张图。
1 | const Koa = require('koa') |
其执行顺序等同于下面的:
1 | function func1() { |
理解了上面两段代码也就大概理解了 koa 的中间件的执行了。
整个系统执行中间件过程如下
koa-compress > koa-bodyparser > koa2-connect-history-api-fallback > koa-favicon > koa-static > commonRouter -> koa-router
其中 commonRouter 为自定义的中间件,内部路由过程如下:
记录开始时间 > 判断登录态 > 执行后续路由 > 回来执行记录结束时间 > 打日志(日志需要有请求时间)
容错、错误码
容错是程序的必要操作,尤其是后端项目,尤其重要,因为一旦报错很可能导致整个系统崩溃。
影响范围极大,为了更好的管理错误,我们最好能做到统一出口、入口,以便能够对错误进行更好的监控,以及异常处理。
可以借助于中间件来完成。
日志(引入log4 -> 日志埋点上报 -> logsearch|kibana查看)
日志也是后端项目必不可少的,nodejs 项目目前比较流行的日志框架有很多
log4js 是目前用的比较多的,其格式也跟其它语言的日志类似。(如 java 的log4j)
log4js:可以做日志收集、写入文件,在服务器直接指定固定目录/data/nodejs/log
1 | data/nodejs/access.log |
本地调试
断点调试是一个很好的习惯,nodejs 最简单快捷的方式就是 console.log 直接控制台查看。
但是,对于复杂的情形,我们也会有需要用到断点调试的时候。
使用 vscode开发,并启动nodejs服务,可以很方便的进行断点 debug。
数据 mock
对于 nodejs 数据 mock 可以有很多方式:
方式一:是用第三方 mock 服务,启动一个mock数据端口static-mock
方式二:利用 webpack 的插件webpack-api-mocker
开发此项目的时候用的是方法二,好处是可以少启动一个端口,mock 可以和 client 的 webpack-dev-server 共享端口。
用到的主要第三方中间件
koa-static:将静态目录映射为路由可访问的路径
koa-favicon:将favicon.ico路径映射为可访问路径并设置max-age缓存头
koa-compress:对请求进行开启gzip压缩,效果很明显(nginx也可以做压缩),压缩之后
response-headers会有这个属性 Content-Encoding:gzip
koa-bodyparser:对于POST请求的处理,koa-bodyparser中间件可以把 koa2 上下文的 formData 数据解析到 ctx.request.body中
koa2-connect-history-api-fallback:对vue history路由做处理,默认将非.xxx后缀请求跳到默认index.html页面
安全 xss、csrf、sql注入
koa-helmet:9个安全中间件的集合、帮助app抵御常见的一些web安全隐患
koa-limit:防止DOS攻击
koa-csrf:防止CSRF攻击
sql注入:对参数进行过滤(见后面附录1)
除此之外,还用到了如下工具:
启动工具 pm2、nodemon、配置、部署、健康检查
redis、sentinels、Medis图形化工具
mysql、mysql连接池、navicat图形化工具
四、踩过的坑
1.favicon.ico 不出来:
1 | app.use(favicon(path.join(__dirname, 'favicon.ico'))) |
1 | <link rel="shortcut icon" href="/favicon.ico" type="image/x-icon"> |
2.直接用浏览器打开接口失败
原因:koa2-connect-history-api-fallback 中间件做了强制跳转
1 | // /server/node_modules/koa2-connect-history-api-fallback/lib/connect-history-api-fallback.js |
解决办法:设置白名单
1 | app.use(historyApiFallback({ whiteList: ['/api/'] })) |
3.ndp环境变量首次设置之后生效,后面修改不生效,不生效
1 | ndp -> 配置 -> 发布配置 -> NODE_ENV |
原因:怀疑是ndp本身的bug,未确定。
解决办法:手动杀掉服务器上pm2进程,重新启动。
4.发布之后进程没有杀死,有一个错误的进程将服务器cpu跑满了。
原因:可能是早期服务代码不完善,报错导致pm2管理失败,后续未重现
解决办法:手动杀掉服务器进程
5.日志打印报错,log4js 本地能写日志文件,服务器上写不了。
原因:
本地开发启动NODE服务的时候只启动一个进程。(需理解进程的概念)
而通过ndp发布之后,自动通过pm2启动,用的是cluster模式,启动了多个进程。
log4js,对于单进程和多进程需要做不同的配置。
解决办法:
1 | // 文档地址: https://log4js-node.github.io/log4js-node/api.html |
6.测试、后端登录我们的项目的时候登录偶尔登录不上,切接口数据更新不及时
原因:配置nginx的时候配置了缓存6min
1 | location / { |
解决办法:
去掉nginx缓存配置 expires选项。
7.每次到一个新的环境,第一次构建都会报模块找不到的错误,重试N次之后正常。
可能原因:
执行build.sh的时候执行的是npm install client && npm install server 安装的总命令
总命令下的子命令 npm install client 等才是真正的安装npm依赖模块
而执行build.sh的时候脚本是同步的,但是只针对脚本内的总命令,不包括子命令
导致npm安装变成异步执行了,在npm未安装完成的情况下执行npm run build导致报错
解决办法:将总命令拆开分别执行安装
1 | registry=https://registry.npm.taobao.org |
8.经过 Nginx 的静态资源和接口返回的数据被截掉了一部分,返回的数据不完整。
问题原因:
新的预发环境nginx配置了缓冲,缓冲过小的时候nginx会将数据写入硬盘,而此时如果没有硬盘文件夹的读取权限,就会出现请求数据被截断的情况。
解决办法:增大缓冲
1 | 在预发环境 和 线上环境的location / 下面配置 proxy_buffers 缓存大小 |
node-mysql中防止SQL注入四种常用方法
方法一:使用 escape 方法对参数进行编码,如
1 | mysql.escape(param); |
escape()方法编码规则:
1 | Numbers不进行转换; |
方法二:使用connection.query()的查询参数占位符
使用”?”作为查询参数占位符。
在使用查询参数占位符的时候,在其内部自动调用 connection.escape() 方法对其传入的参数进行编码,如:
1 | let post = { name: 'namestring' } |
方法三:使用escapedId()编码SQL查询标识符
1 | mysql.escapedId(identifier) |
方法四:使用mysql.format()转义参数
准备查询,此方法用于准备查询语句,该函数会自动选择合适的转义参数。