之家论坛前后端分离实战

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 等特性,但 KoaExpress 一样都太过于基础,多进程管理/通信/守护等都需要工程师关注和封装,需要在基础架构上下更多的功夫。

选型原则: 选型时,当然要选择社区活跃的技术。 ExpressKoa 在 NPM 统计上名列前两名。由于过于基础而弃选,而 Egg 是在 Koa 上封装的企业框架,约定优于配置 的理念也符合我们团队现状,尽可能降低协作成本,人员可以随意流动,且提供基于 Egg 定制上层框架的能力,种种特性都很不错。

再基于论坛的业务场景和公司交互规范,我们最终选择了 Egg 作为服务端框架;同时,放弃 Vue 同构方案,论坛业务在目前公司的交互规范下(太多的新窗口打开模式),同构失去了它的优越性,即使使用也只是单纯地在服务端用作渲染引擎,而相比于传统的 nunjucksejs 等渲染引擎,它也没有什么优点让人不得不选。

作用: Egg 在这次论坛技术改版中主要有两个作用,一个是用作 Render 输出页面,一个是用作 ApiProxy

工作流

目前我们团队的开发模式是 create-autofe-app(团队自搭CLI简称CAA) 结合 Egg。其实只是把以前交由后端同学套页面的工作,现由自己的团队消化解决。比如来了新需求,一个人可以使用 CAA 开发静态页面(nunjucks 模版,和 Egg 渲染模版切合),另一个人可以同时写 NodeProxyApi 供前一个人使用,等静态页面完成就开始承接服务端模版渲染工作;也可以一个人搞定所有。这种开发方式下, CAA 提供了一份可维护的纯前端 html/JS/CSS 代码库, 且相同的模版减少了很多重复工作。

中间层目录规划

选择 Egg 作为服务端框架的最重要原因之一就是其约束性。所以我们在目录规划上花了一点时间来让团队成员理解和适应。目录结构的规划集中在 controllerservice 层面上。

经过多次改造,最终确定 controller 层主要有两个目录, /pages/api,基于 node 中间层的作用 ( Render&Proxy) 而定。

但是 service 不同, service 主要作用是抽离出更高可复用的接口服务;在论坛业务中,不仅仅依赖自己的业务接口,还有一些其他业务展示模块依赖接口或公用的用户账号接口等,所以最终定为以 业务线 为目录的管理方式,比如 service/club 代表论坛提供的接口服务, service/uc 代表用户中心维护的接口服务, service/qa 代表问答业务线等等,再深层次的目录为领域模块。

service 的结构也决定了 controller 下的同样配置。所以最终 app 下的 子目录 如下:

 
   
   
 
  1. app

  2. ├── controller

  3. ├── api

  4. ├── carowner

  5. ├── club

  6. └── qa

  7. └── pages

  8. ├── extend

  9. ├── middleware

  10. ├── public

  11. ├── router

  12. ├── schedule

  13. ├── service

  14. ├── carowner

  15. ├── club

  16. ├── common

  17. ├── qa

  18. └── uc

  19. └── view

  20. ├── home

  21. └── partials

  22. └── layouts

  23. ├── footer

  24. ├── header

  25. └── script

定时任务

在 18 年 10 月左右,我司产品要求统一网站导航头维护,为此我们前端团队技术 leader 用 Express 搭建了接口服务,要求各个对接业务每 10 分钟从该微服务接口抓取内容缓存到各自业务服务器。所以我们也需要实现相关功能,在 view 层也就有了定时任务的特殊处理。在这里主要涉及 fs 核心模块的使用。因为这个任务是应用提供功能的前置任务,所以我们要确保应用在公共头文件确实生成后才启动,也就使用了同步 API (在 node 环境中绝大部分都是异步代码)。这里需要注意文件目录的递归判断。

 
   
   
 
  1. // app/schedule/update_common_view.js

  2. 'use strict';

  3. const Subscription = require('egg').Subscription;

  4. const fs = require('fs');

  5. const path = require('path');

  6. class UpdateCommonView extends Subscription {

  7. static get schedule() {

  8. return {

  9. interval: '10m',

  10. type: 'worker',

  11. };

  12. }

  13. async subscribe() {

  14. // 确定存放目录

  15. const headerViewDir = path.join(__dirname, '../view/layouts/header');

  16. const footerViewDir = path.join(__dirname, '../view/layouts/footer');

  17. const ctx = this.ctx;

  18. if (!fs.existsSync(headerViewDir)) { // 同步判断

  19. try {

  20. fs.mkdirSync(headerViewDir, { recursive: true }); // 同步递归生成目录

  21. } catch (e) {

  22. this.logger.error(e);

  23. }

  24. }

  25. if (!fs.existsSync(footerViewDir)) {

  26. try {

  27. fs.mkdirSync(footerViewDir, { recursive: true });

  28. } catch (e) {

  29. this.logger.error(e);

  30. }

  31. }

  32. try {

  33. await Promise.all([

  34. ctx.service.common.view.updateDarkHeader(headerViewDir),

  35. ctx.service.common.view.updateDarkFooter(footerViewDir),

  36. ]);

  37. } catch (e) {

  38. this.logger.error(e);

  39. }

  40. }

  41. }

  42. module.exports = UpdateCommonView;

对应的 service 如下(省略部分代码):

 
   
   
 
  1. 'use strict';

  2. const Service = require('egg').Service;

  3. const fs = require('fs');

  4. const path = require('path');

  5. class ViewService extends Service {

  6. /**

  7. * [黑色]公共头 模版文件

  8. * @param {String} absolutePath - 系统绝对路径

  9. * @param {String} fileName - 文件名

  10. * @memberof ViewService

  11. */

  12. async updateDarkHeader(absolutePath, fileName = 'dark.html') {

  13. const feAPIUrl = this.config.api_url.front;

  14. try {

  15. const res = await this.ctx.curl(`${feAPIUrl}/topbar/club`, {

  16. data: {

  17. theme: 'dark',

  18. club_id: 0,

  19. },

  20. });

  21. if (res.data) {

  22. this._writeFile(path.join(absolutePath, fileName), res.data);

  23. } else {

  24. this.logger.error(new Error('===更新黑色公共头失败==='));

  25. }

  26. } catch (e) {

  27. this.logger.error(e);

  28. }

  29. }

  30. _writeFile(file, data) {

  31. try {

  32. fs.writeFileSync(file, data);// 同步写入

  33. } catch (e) {

  34. this.logger.error(e);

  35. }

  36. }

  37. }

  38. 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 来解决错误捕获和自定义日志问题。如下:

 
   
   
 
  1. // app/core/base_service.js

  2. 'use strict';

  3. const Service = require('egg').Service;

  4. class BaseService extends Service {

  5. checkApiResult(result) {

  6. if (result.status !== 200) {

  7. const ressultMsg = result.data && result.data.message;

  8. const errorMsg = ressultMsg ? ressultMsg : 'unknown origin server error';

  9. this.ctx.throw(result.status, errorMsg); // 这里

  10. }

  11. }

  12. }

  13. module.exports = BaseService;

 
   
   
 
  1. // app/middleware/error_handler.js

  2. module.exports = () => {

  3. return async function errorHandler(ctx, next) {

  4. try {

  5. await next();

  6. } catch (e) {

  7. ctx.app.emit('error', e, ctx);

  8. ... // some other stuff

  9. }

  10. };

  11. };

上面使用了 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 提供的原始日志格式不能满足目前需求就得自己改造,改造如下:

 
   
   
 
  1. // app/extend/application.js

  2. /**

  3. * 自定义 logger 格式

  4. * 以适配 UGC 的错误日志上报系统

  5. */

  6. 'use strict';

  7. class ContextLogger {

  8. /**

  9. * @constructor

  10. * @param {Context} ctx - egg Context instance

  11. * @param {Logger} logger - Logger instance

  12. */

  13. constructor(ctx, logger) {

  14. this.ctx = ctx;

  15. this._logger = logger;

  16. }

  17. // 省略...

  18. contextFormatter(meta) {

  19. return meta.date + ' ' + meta.level + ' ' + meta.pid + ' ' + meta.paddingMessage + ' ' + meta.message;

  20. }

  21. }

  22. // 以下为主要改造点

  23. [ 'error', 'warn', 'info', 'debug' ].forEach(level => {

  24. const LEVEL = level.toUpperCase();

  25. ContextLogger.prototype[level] = function() {

  26. const meta = {

  27. ctx: this.ctx,

  28. formatter: this._logger.options[`${level}Formatter`] || this.contextFormatter, // 不同等级日志

  29. };

  30. this._logger.log(LEVEL, arguments, meta);

  31. };

  32. });

  33. module.exports = {

  34. ContextLogger,

  35. };

不同等级的日志,可配置不同的日志格式,通过在 config 中配置格式化函数,使用如下:

 
   
   
 
  1. // config.dafault.js

  2. // 日志

  3. config.logger = {

  4. errorLogName: 'error.log',

  5. errorFormatter(meta) {

  6. const { ctx, date, level, message } = meta;

  7. return `${moment(date).format('YYYY-MM-DD HH:mm:ss')}\`${level}\`${ctx.url}\`${message}`;

  8. },

  9. };

对于不依赖阿里云平台的项目,个人感觉, egg-logger 不是那么好用,format 不可配置化,需要去看源码解决问题。但尊重开源开发者的贡献,不满足需求的情况,可以自己造嘛。以上改造都是为了适应内部的 filebeat 服务。

ES

写日志,当然是为了方便定位/分析问题。也就有了可视化的需求,所以我们要上报到内部 ES 集群,比如上报必要的错误/Debug/访问信息。上面所说的 filebeat 就能实现。当然我们也可以使用 elasticsearch node 包,直接发送信息到 ES。

比如,自定义全局错误监听函数如下:

 
   
   
 
  1. // app.js

  2. const util = require('util');

  3. const elasticsearch = require('elasticsearch');

  4. const shortid = require('shortid');

  5. const moment = require('moment');

  6. module.exports = app => {

  7. const esClient = new elasticsearch.Client({

  8. host: app.config.esURI, // 测试/生产不同

  9. });

  10. // 自定义全局 error 事件监听

  11. app.on('error', async (e, ctx) => {

  12. try {

  13. const currentTime = new Date();

  14. const momentTime = moment(currentTime).utcOffset(8); // 时区偏差

  15. const logBody = {

  16. '@timestamp': currentTime, // 创建索引时手动配置该时间戳

  17. level: 'ERROR',

  18. message: err.message,

  19. exception: util.format(e.stack),

  20. processid: process.pid,

  21. clientip: ctx.ip,

  22. url: ctx.href,

  23. method: ctx.method,

  24. query: util.format(ctx.query),

  25. reqbody: util.format(ctx.request.body),

  26. ua: ctx.header['user-agent'],

  27. datetime: momentTime.format('YYYY-MM-DD HH:mm:ss SSS'),

  28. };

  29. if (ctx.locals) { // 为了定位渲染失败问题

  30. logBody.ctxlocals = util.format(ctx.locals);

  31. }

  32. await esClient.create({

  33. index: `nodejs_err_${momentTime.format('YYYY.MM.DD')}`,// 按天生成索引

  34. type: 'table',

  35. id: shortid.generate(),

  36. body: logBody,

  37. });

  38. } catch (e) {

  39. app.logger.error(e);

  40. }

  41. });

  42. };

有遇到时区问题,用 UTC 时间计算一下即可。

Docker

在使用 Docker 容器的时候,注意去掉 package.jsonstart 脚本的 --daemon:

 
   
   
 
  1. "scripts": {

  2. "start": "egg-scripts start --title=egg-club-web"

  3. }

在出现 502 网关错误时,可以检查下启动 host 配置(config.prod.js),不要使用 127.0.0.1 来启动应用,改用 0.0.0.0 来启动。

结语

以上都是在项目初始阶段所走过的一些路,在开发过程中,NodeJS 核心模块使用的不是很多, os 模块有用到取 ServerIP,后因采用容器而去掉;也就集中在 pathfs 模块;业务代码中更多的还是在 ECMAScript 使用上,所以没有什么难度。作为一枚前端程序猿,服务端不可怕,可怕的是不敢尝试,不思进取。日积跬步,方至千里。

之家论坛前后端分离实战

作者简介:

2016年加入汽车之家用户产品中心前端团队,目前负责互动相关业务的 Web 前端日常管理和开发支持工作,擅长使用 React/ESNext 架构开发 Web 应用,主导负责 Node SSR 中间层搭建,对前后分离开发模式有丰富的实战经验。


以上是关于之家论坛前后端分离实战的主要内容,如果未能解决你的问题,请参考以下文章

22.前后端项目部署实战

Django 3 + Vue.js 前后端分离Web开发实战

Ruoyi前后端分离式开源项目实战部署总结-本地部署测试

Ruoyi前后端分离式开源项目实战部署总结-部署测试

推荐 9 个 yyds 前后端分离项目

第二季SpringBoot+Vue前后端分离项目实战笔记