之家论坛前后端分离实战
Posted 之家技术
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了之家论坛前后端分离实战相关的知识,希望对你有一定的参考价值。
总篇34篇 2019年第8篇
前言
同学们都知道,在前端圈依然处于火热状态的技术如 React、Vue,其开发过程是依赖于 NodeJS 环境的;但它们的使用场景早已不单纯的限制在浏览器等客户端, SSR
同构方案,也在吸引着广大前端攻城狮学习和运用,这时生产线就会依赖于 NodeJS
环境,等等这些场景足见 NodeJS
在程序开发中的影响力。而这些技术不但知名于前端圈,传统的后端同学或多或少也知晓一点,这也就为我们这次改版提供了一些共识。
作为有理想、有抱负、想统一前后端技术栈的高大上前端程序猿, NodeJS
也就成为其手中最有利的武器之一。我们前端团队也一直在储备相关知识并努力将其应用于面向 C 端的应用中,但不是为了上而上,需要考虑各种实际场景。像我们这种有历史技术债务的公司,在技术转型/迁移过程中都会有一些痛点,而日趋成熟的 NodeJS
为其提供了一种强有力的备选方案,也使我们前端同学有了更广阔的天地。
基于以上所述,并结合业内技术环境、公司环境和论坛业务需要着重考虑 SEO 的场景,在这次改版规划中,我们前后端同学共同决定引入 NodeJS
作为前后分离解决方案。
下面就为大家简要描述下,团队一段时间以来踩过的坑,积累的经验,也算是适用于我们团队自己的最佳实践吧。
背景&选型
业务背景: 论坛作为互动业务线中最大的一条业务线,有庞大的用户访问量,单纯 PC 端 日PV就在千万级别,M 端更是它的好几倍,业务场景丰富,交互方式也趋于多样,前端责任也越来越重。但 稳定性 排在首位考虑是毋庸置疑的。
人力资源: 互动团队前端有 7 人,每个人都至少对接一条业务线,当选择 NodeJS
作为前后分离的技术解决方案时,必须考虑比较现实的 人力成本 问题。
时间成本/项目周期: 在往常 Vue/React
前后分离实战中,前端的排期时间就比普通的提供静态交互页面时间长了大约 2 倍,需要关注更多的业务展示逻辑,用例评审,当然带来了更好的用户体验,可维护性更高的前端代码;但前后端并行开发也没有延长很多的排期时间。若考虑到使用 NodeJS
的场景,我们必需额外的关注服务端的稳定性/健壮性,错误的可跟踪性等等。如果在同时考虑同构方案,前端的开发成本,维护成本,人力成本都会显著提升。
技术经历: 通过一段时间 Express
的使用,笔者认识到它应该算是入门级别的框架,作为自学和个人项目很适合。但作为企业服务就有点差强人意了。松散的结构,基础的中间件模式,相对于 Koa
洋葱式的中间件它缺少了灵活性。而最新版本的 Koa
虽然有更新的技术基础,天然支持 async
等特性,但 Koa
和 Express
一样都太过于基础,多进程管理/通信/守护等都需要工程师关注和封装,需要在基础架构上下更多的功夫。
选型原则: 选型时,当然要选择社区活跃的技术。 Express
和 Koa
在 NPM 统计上名列前两名。由于过于基础而弃选,而 Egg
是在 Koa
上封装的企业框架,约定优于配置 的理念也符合我们团队现状,尽可能降低协作成本,人员可以随意流动,且提供基于 Egg 定制上层框架的能力,种种特性都很不错。
再基于论坛的业务场景和公司交互规范,我们最终选择了 Egg
作为服务端框架;同时,放弃 Vue
同构方案,论坛业务在目前公司的交互规范下(太多的新窗口打开模式),同构失去了它的优越性,即使使用也只是单纯地在服务端用作渲染引擎,而相比于传统的 nunjucks
, ejs
等渲染引擎,它也没有什么优点让人不得不选。
作用: Egg
在这次论坛技术改版中主要有两个作用,一个是用作 Render
输出页面,一个是用作 ApiProxy
。
工作流
目前我们团队的开发模式是 create-autofe-app(团队自搭CLI简称CAA)
结合 Egg
。其实只是把以前交由后端同学套页面的工作,现由自己的团队消化解决。比如来了新需求,一个人可以使用 CAA
开发静态页面(nunjucks 模版,和 Egg 渲染模版切合),另一个人可以同时写 NodeProxyApi
供前一个人使用,等静态页面完成就开始承接服务端模版渲染工作;也可以一个人搞定所有。这种开发方式下, CAA
提供了一份可维护的纯前端 html/JS/CSS
代码库, 且相同的模版减少了很多重复工作。
中间层目录规划
选择 Egg
作为服务端框架的最重要原因之一就是其约束性。所以我们在目录规划上花了一点时间来让团队成员理解和适应。目录结构的规划集中在 controller
和 service
层面上。
经过多次改造,最终确定 controller
层主要有两个目录, /pages
和 /api
,基于 node 中间层的作用 ( Render&Proxy
) 而定。
但是 service
不同, service
主要作用是抽离出更高可复用的接口服务;在论坛业务中,不仅仅依赖自己的业务接口,还有一些其他业务展示模块依赖接口或公用的用户账号接口等,所以最终定为以 业务线 为目录的管理方式,比如 service/club
代表论坛提供的接口服务, service/uc
代表用户中心维护的接口服务, service/qa
代表问答业务线等等,再深层次的目录为领域模块。
service
的结构也决定了 controller
下的同样配置。所以最终 app
下的 子目录 如下:
app
│ ├── controller
│ │ ├── api
│ │ │ ├── carowner
│ │ │ ├── club
│ │ │ └── qa
│ │ └── pages
│ ├── extend
│ ├── middleware
│ ├── public
│ ├── router
│ ├── schedule
│ ├── service
│ │ ├── carowner
│ │ ├── club
│ │ ├── common
│ │ ├── qa
│ │ └── uc
│ └── view
│ ├── home
│ │ └── partials
│ └── layouts
│ ├── footer
│ ├── header
│ └── script
定时任务
在 18 年 10 月左右,我司产品要求统一网站导航头维护,为此我们前端团队技术 leader 用 Express
搭建了接口服务,要求各个对接业务每 10 分钟从该微服务接口抓取内容缓存到各自业务服务器。所以我们也需要实现相关功能,在 view
层也就有了定时任务的特殊处理。在这里主要涉及 fs
核心模块的使用。因为这个任务是应用提供功能的前置任务,所以我们要确保应用在公共头文件确实生成后才启动,也就使用了同步 API (在 node 环境中绝大部分都是异步代码)。这里需要注意文件目录的递归判断。
// app/schedule/update_common_view.js
'use strict';
const Subscription = require('egg').Subscription;
const fs = require('fs');
const path = require('path');
class UpdateCommonView extends Subscription {
static get schedule() {
return {
interval: '10m',
type: 'worker',
};
}
async subscribe() {
// 确定存放目录
const headerViewDir = path.join(__dirname, '../view/layouts/header');
const footerViewDir = path.join(__dirname, '../view/layouts/footer');
const ctx = this.ctx;
if (!fs.existsSync(headerViewDir)) { // 同步判断
try {
fs.mkdirSync(headerViewDir, { recursive: true }); // 同步递归生成目录
} catch (e) {
this.logger.error(e);
}
}
if (!fs.existsSync(footerViewDir)) {
try {
fs.mkdirSync(footerViewDir, { recursive: true });
} catch (e) {
this.logger.error(e);
}
}
try {
await Promise.all([
ctx.service.common.view.updateDarkHeader(headerViewDir),
ctx.service.common.view.updateDarkFooter(footerViewDir),
]);
} catch (e) {
this.logger.error(e);
}
}
}
module.exports = UpdateCommonView;
对应的 service
如下(省略部分代码):
'use strict';
const Service = require('egg').Service;
const fs = require('fs');
const path = require('path');
class ViewService extends Service {
/**
* [黑色]公共头 模版文件
* @param {String} absolutePath - 系统绝对路径
* @param {String} fileName - 文件名
* @memberof ViewService
*/
async updateDarkHeader(absolutePath, fileName = 'dark.html') {
const feAPIUrl = this.config.api_url.front;
try {
const res = await this.ctx.curl(`${feAPIUrl}/topbar/club`, {
data: {
theme: 'dark',
club_id: 0,
},
});
if (res.data) {
this._writeFile(path.join(absolutePath, fileName), res.data);
} else {
this.logger.error(new Error('===更新黑色公共头失败==='));
}
} catch (e) {
this.logger.error(e);
}
}
_writeFile(file, data) {
try {
fs.writeFileSync(file, data);// 同步写入
} catch (e) {
this.logger.error(e);
}
}
}
module.exports = ViewService;
以上写文件用的同步方式, writeFileSync
,如果是异步写文件方式,推荐使用回调 err 错误信息捕获文件是否存在,而不是先通过 fs.exist(弃用)/fs.access/fs.stats
判断文件状态再写入的方式,因为中间别的进程也可能影响文件的状态。
而这里,笔者曾经犯了点错误, ViewService
中,最初在内部写死了 absolutePath 和 fileName;后来 review 代码,发现 UpdateCommonView
中存在目录判断,与 service 中方法高度冗余且不能复用,才提取的目录和文件名。
错误捕获
在上面的定时任务代码中,使用的是 trycatch
来捕获错误和打印日志的。但该定时任务与普通的 api service
不同,首先它是需要写文件的而且是同步写入,因为导航头是前置准备工作,需要确保优先写入文件,再对外提供服务。 trycatch
也是在 async
语法使用过程中常用的错误捕获方式。
在 service
中,常见的一种需求/错误产生原因是验证接口返回以及数据反序列化。所以每个接口方法都有一个 check result
的工作。在最开始,我们使用 BaseService
的方式提供公共方法,service 目录下的文件都是以它为基类而构建的;继而使用 middleware
来解决错误捕获和自定义日志问题。如下:
// app/core/base_service.js
'use strict';
const Service = require('egg').Service;
class BaseService extends Service {
checkApiResult(result) {
if (result.status !== 200) {
const ressultMsg = result.data && result.data.message;
const errorMsg = ressultMsg ? ressultMsg : 'unknown origin server error';
this.ctx.throw(result.status, errorMsg); // 这里
}
}
}
module.exports = BaseService;
// app/middleware/error_handler.js
module.exports = () => {
return async function errorHandler(ctx, next) {
try {
await next();
} catch (e) {
ctx.app.emit('error', e, ctx);
... // some other stuff
}
};
};
上面使用了 ctx.throw()
抛出错误。如果抛出的错误是被 trycatch
捕获到的,用 ctx.app.emit('error',err,ctx);
暴露给全局的 error
事件,会同时打印错误日志和应用日志。当然官方也提供了 onerror
插件,供开发者在 config
目录配置使用。
我们在这里走过的弯路就是打印了过多的重复日志,最初我们有使用错误中间件,并自定义配置了全局错误监听函数 app.on('error',()=>{})
和 onerror 插件,都打印了日志,最后只保留全局 error 事件。
Service
最初 service
的开发,是每个方法都写很多 try catch 和各种 logger 调用,造成这种写法的原因是 我们是否要在 service 层判断并转换接口返回结果,如过滤掉结果中的 message 和 returncode,只对外提供 result。也是因为先写 render 所需的 service 造成。而随着开始写 proxy 所依赖的 service,并使用 egg-validate
插件时终于意识到了问题。
我们使用 egg-validate
插件验证用户输入,并反馈给客户端,但后来发现,这样做之后的错误提示与之家的内部接口错误返回提示不一致,还涉及到接口错误统一的提示状态码问题,最终决定不对输入做验证(还是交由内部接口验证,Node 中间层原样转发请求体),也不对返回结果特殊处理,只验证接口结果的 HTTP 状态码,并打印错误日志,一般就是原样返回。这样就保证了所有 service 书写一致性。
对于官方提供的一些功能插件,视情况决定是否需要开启,没必要的就禁用掉,等迭代过几次后就可以根据实际业务场景,封装更高一层的公司框架。
Log
因为互动有统一的日志收集系统,接入的话就需要符合内部的日志格式,而 egg-logger
提供的原始日志格式不能满足目前需求就得自己改造,改造如下:
// app/extend/application.js
/**
* 自定义 logger 格式
* 以适配 UGC 的错误日志上报系统
*/
'use strict';
class ContextLogger {
/**
* @constructor
* @param {Context} ctx - egg Context instance
* @param {Logger} logger - Logger instance
*/
constructor(ctx, logger) {
this.ctx = ctx;
this._logger = logger;
}
// 省略...
contextFormatter(meta) {
return meta.date + ' ' + meta.level + ' ' + meta.pid + ' ' + meta.paddingMessage + ' ' + meta.message;
}
}
// 以下为主要改造点
[ 'error', 'warn', 'info', 'debug' ].forEach(level => {
const LEVEL = level.toUpperCase();
ContextLogger.prototype[level] = function() {
const meta = {
ctx: this.ctx,
formatter: this._logger.options[`${level}Formatter`] || this.contextFormatter, // 不同等级日志
};
this._logger.log(LEVEL, arguments, meta);
};
});
module.exports = {
ContextLogger,
};
不同等级的日志,可配置不同的日志格式,通过在 config
中配置格式化函数,使用如下:
// config.dafault.js
// 日志
config.logger = {
errorLogName: 'error.log',
errorFormatter(meta) {
const { ctx, date, level, message } = meta;
return `${moment(date).format('YYYY-MM-DD HH:mm:ss')}\`${level}\`${ctx.url}\`${message}`;
},
};
对于不依赖阿里云平台的项目,个人感觉, egg-logger
不是那么好用,format 不可配置化,需要去看源码解决问题。但尊重开源开发者的贡献,不满足需求的情况,可以自己造嘛。以上改造都是为了适应内部的 filebeat 服务。
ES
写日志,当然是为了方便定位/分析问题。也就有了可视化的需求,所以我们要上报到内部 ES 集群,比如上报必要的错误/Debug/访问信息。上面所说的 filebeat 就能实现。当然我们也可以使用 elasticsearch
node 包,直接发送信息到 ES。
比如,自定义全局错误监听函数如下:
// app.js
const util = require('util');
const elasticsearch = require('elasticsearch');
const shortid = require('shortid');
const moment = require('moment');
module.exports = app => {
const esClient = new elasticsearch.Client({
host: app.config.esURI, // 测试/生产不同
});
// 自定义全局 error 事件监听
app.on('error', async (e, ctx) => {
try {
const currentTime = new Date();
const momentTime = moment(currentTime).utcOffset(8); // 时区偏差
const logBody = {
'@timestamp': currentTime, // 创建索引时手动配置该时间戳
level: 'ERROR',
message: err.message,
exception: util.format(e.stack),
processid: process.pid,
clientip: ctx.ip,
url: ctx.href,
method: ctx.method,
query: util.format(ctx.query),
reqbody: util.format(ctx.request.body),
ua: ctx.header['user-agent'],
datetime: momentTime.format('YYYY-MM-DD HH:mm:ss SSS'),
};
if (ctx.locals) { // 为了定位渲染失败问题
logBody.ctxlocals = util.format(ctx.locals);
}
await esClient.create({
index: `nodejs_err_${momentTime.format('YYYY.MM.DD')}`,// 按天生成索引
type: 'table',
id: shortid.generate(),
body: logBody,
});
} catch (e) {
app.logger.error(e);
}
});
};
有遇到时区问题,用 UTC 时间计算一下即可。
Docker
在使用 Docker 容器的时候,注意去掉 package.json
中 start
脚本的 --daemon
:
"scripts": {
"start": "egg-scripts start --title=egg-club-web"
}
在出现 502 网关错误时,可以检查下启动 host 配置(config.prod.js),不要使用 127.0.0.1
来启动应用,改用 0.0.0.0
来启动。
结语
以上都是在项目初始阶段所走过的一些路,在开发过程中,NodeJS 核心模块使用的不是很多, os
模块有用到取 ServerIP
,后因采用容器而去掉;也就集中在 path
和 fs
模块;业务代码中更多的还是在 ECMAScript 使用上,所以没有什么难度。作为一枚前端程序猿,服务端不可怕,可怕的是不敢尝试,不思进取。日积跬步,方至千里。
作者简介:
2016年加入汽车之家用户产品中心前端团队,目前负责互动相关业务的 Web 前端日常管理和开发支持工作,擅长使用 React/ESNext 架构开发 Web 应用,主导负责 Node SSR 中间层搭建,对前后分离开发模式有丰富的实战经验。
以上是关于之家论坛前后端分离实战的主要内容,如果未能解决你的问题,请参考以下文章