Egg.js构建云站中台

2021/8/25 NodeEgg.js

整理下云站中台构建的思路与过程。

# 前言现状

云站是公司销售端的网站。由 Java 的 Spring 框架 + Velocity 模版 组成。由于前后端代码高度耦合,且前端路由被后端 Java 代码控制,对于开发、管理十分不便。

也由于现在对前后端分离的观点占据主流,在招聘时,部分前端开发者在了解了云站的代码结构后,萌生退意;还发生过一件事,招来的前端开发,来了一天就离开了。

虽然代码不前后分离,但是项目中使用了 Gulp、ES6等相对较新的技术,还是有些可取之处的。

以上种种,让我们产生了更新框架技术的想法。根据现状,我整理出来以下几点必须满足的需求:

  1. SEO:我们需要在Google中做营销,所以必须要支持。
  2. 服务端渲染速度:在 Java 技术中,服务端从开始访问到渲染后的数据仅有30ms~50ms左右;因为技术限制,我们可以忍受中台速度需要不超过60ms~80ms。
  3. 页面整体速度:网络正常的情况下,需要在1s内。
  4. 快速迁移:因为历史代码比较多,时间有限制,需要想办法尽快实现。
  5. 功能一致:包括站点定义

# 性能测试

其实有大公司的背书,Egg.js框架本来是足够可以信任的,但是还是需要测试下。

写了个 Demo,打印了下渲染的时间(仅render部分),1w DOM,渲染用时 50ms 左右。勉强可以接受。

# 迁移代码

根据分析可知,迁移代码时,需要包含 java 的 controller 的部分、 Velocity 的模版部分。

为了可以批量操作,我需要准备好 Velocity => EJS 的代码应对关系、 Java Controller => Router;其他代码可以随遇随处理。

# 路由

在 Java 的 controller 文件中的下面代码,代表了路由地址,根据 Get Post Delete Put 等等来处理。

// /client-web/src/main/java/com/client/controller/AccountController.java
@RequestMapping(value = "account/recover")
@GetMapping(value = "account/logout")
@PostMapping(value = "account/logout")
// ...
1
2
3
4
5
// egg/router.js
module.exports = app => {
  const { router, controller } = app;
  // account
  router.get('/subscribe_mail', controller.subscriber.mail); // 邮件订阅 [仅M端]
}
1
2
3
4
5
6

# 模版

.vm => .ejs 常用的语法在下面,注意其中部分是 java 的函数需要转换成 javascript 的。

#springMessage  =>  <%- __(' ') %>
#paramSpringMessage  =>  <%- __('view.goodsDetail.charLeft.limitItem{0}', [val.NumberLength]) %>

#foreach  =>  <% SITE.forEach(function(fbKey){ %>
.entrySet()  =>  <% for (var key of object) { %>

#if  =>  <% if(){ %>
#else  =>  <% } else { %>
#elseif  =>  <% } else if() { %>
#end  =>  <% } %>

##  =>  <%#  %>

.size()  =>  .length

#set($a = )  =>  <% var a = ''; %>

#parse("/common/cloud_toggles.vm")  =>  <%- helper.cloud_toggles(siteSwitch, SITE.openSubscribe) %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 模版中的函数

velocity/macro.vm => eggg.js/helper

// /app/extend/helper.js
// #macro(iif $cond $y $n)#if($cond)$y#else$n#end#end
// 三元表达式在java中不支持,但在js中本就支持这里可以写函数为了方便批量替换,自己写还是写三元比较好
const iif = (cond, y, n) => {
  return cond ? y : n;
};
1
2
3
4
5
6

# Java Builder

Egg.js中没有这个概念,如果需要使用外部的包,可以直接 require 后,调用;这里可以写一个工具函数,然后在 helper 中 require 进来后调用。

module.exports = {
  urlBuilder,
};

const { urlBuilder } = require('../utils/builder/UrlBuilder.js'); // 这里的builder,只是为了好找,没有什么意义
1
2
3
4
5

另:因为在 builder 中,又看到了 Utils、Consts 的引用,所以还是写一个工具函数引入调用即可。

# 遇到问题

在工作中遇到了些问题,可以引以为鉴。

# 问题一:发布测试机后,发现性能不达标

仅渲染时间就达到了 300ms+ ,而要求是 80ms。

经过排查,发现:由于我想在测试环境中减小代码的体积,增加了实时压缩的三方插件 html-minifier (因为项目中使用grunt,为了快速迁移,没有引入webpack)。

而在服务器上压缩,由于性能不足,导致测试没有通过,临时禁用后,性能测试达标。

// ⚠️废弃 html压缩
const minifier = require('html-minifier');
async function htmlZip(ctx, data, html_code) {
  // 压缩  cpu压力大,影响访问速度,禁用
  try {
    return minifier.minify(html_code, { collapseWhitespace: true, removeComments: true, minifyCSS: true, minifyJS: true });
  } catch (err) {
    ctx.oplogger.error(new Error('压缩异常:' + err.message.toString()));
    console.log('压缩异常:' + err.message.toString().slice(0, 200));
    await ctx.render('/error.ejs', { mes: data && data.data ? err.message.toString() : '500 - Service Error' });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

# 问题二:多语言处理异常

开始我想手动处理数据,但是发现谁太多,且更新也太多,需要写一个脚本来处理。

/Users/leo/Desktop/repo/Tools-by-node/Properties-to-Json java代码中的properties文件转换成egg项目中的json文件

# 问题三:日志记录

为了便于查找问题,需要记录下日志。为了方便给后台传递参数request-id,我在extend中定义了一个函数用来记录日志。

// /app/extend/context.js
module.exports = {
  get oplogger() {
    const ctx = this;
    return {
      debug(...p) {
        if (p.length === 0) return;
        p[0] = '[request-id: ' + ctx.request.requestId + '] ' + (p[0] || '');
        ctx.logger.debug(...p);
      },
      info(...p) {
        if (p.length === 0) return;
        p[0] = '[request-id: ' + ctx.request.requestId + '] ' + (p[0] || '');
        ctx.logger.info(...p);
      },
      warn(...p) {
        if (p.length === 0) return;
        p[0] = '[request-id: ' + ctx.request.requestId + '] ' + (p[0] || '');
        ctx.logger.warn(...p);
      },
      error(...p) {
        if (p.length === 0) return;
        p[0] = '[request-id: ' + ctx.request.requestId + '] ' + (p[0] || '');
        ctx.logger.error(...p);
      },
      request(...p) {
        if (p.length === 0) return;
        p[0] = '[request-id: ' + ctx.request.requestId + '] ' + (p[0] || '');
        const reqLogger = ctx.getLogger('reqLogger');
        reqLogger.info(...p);
      },
    };
  },
};
// /app/middleware/requestId.js
module.exports = () => {
  return async function requestId(ctx, next) {
    const start = Date.now();
    const requestId = uuid.v4();
    ctx.set('x-request-id', requestId);
    ctx.set('x-powered-by', 'mf');
    ctx.request.requestId = requestId;
    await next();
    const end = Date.now();
    // 记录render时间
    let renderTime = 0
    if (ctx.response.header && ctx.response.header['api-timing']) {
      renderTime = end - start - ctx.response.header['api-timing']
    }
    ctx.oplogger.info(`[referer: ${ctx.request.header.referer}] [user-agent: ${ctx.request.header['user-agent']}] end of request time cost: ${end - start}ms${renderTime ? `; render time cost: ${renderTime}ms` : ''}`);
  };
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

# 问题四:打印curl的timing

因为性能问题,需要查询打印请求的时间,我发现了一个小问题:contentDownload参数看翻译像是文档下载时间,但是实际含义是全部响应数据接收完毕耗时,是我理解的有误,差点以为发现了啥大问题。点击查看官方文档 (opens new window)

# 问题五:发现了异常的访问,一直访问到404

没有注意到map文件,补上之后就好了。

# 问题六:favicon.ico

因为需要有多个站点,可以在后台配置favicon.ico。文档中我没有找到合适的处理方法,所以想了个办法:

  1. 重定向egg访问的 favicon.ico 至 favicon
  2. router处理icon
  3. 根据后台配置,处理图片并返回
// /config/config.default.js
config.siteFile = {
  '/favicon.ico': '/favicon',
};
// /app/router.js
router.get('/favicon', controller.icon.favicon); // favicon [资源]
// /app/controller/icon.js
async favicon() {
  const { ctx } = this;
  const src = await faviconSrc(ctx);
  await renderPic(ctx, src);
}
// renderPic
// 通过图片地址取buffer并返回
async function renderPic(ctx, src) {
  const pic = await new Promise((resolve) => {
    const req = https.get(src, function(res) {
      const chunks = []; // 用于保存网络请求不断加载传输的缓冲数据
      let size = 0; // 保存缓冲数据的总长度
      res.on("data", function(chunk) {
        chunks.push(chunk);
        // 累加缓冲数据的长度
        size += chunk.length;
      });
      res.on("end", function(err) {
        // Buffer.concat将chunks数组中的缓冲数据拼接起来,返回一个新的Buffer对象赋值给data
        resolve(Buffer.concat(chunks, size));
        console.log(err, "renderPic");
      });
    });
    req.end();
  });
  ctx.status = 200;
  ctx.response.type = "image/png+jpg+jpeg+gif";
  ctx.body = pic;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

# 后续

# TS

后面可以给模版部分增加ts支持,在grunt中添加编译即可。

Egg也可以支持,因为人力问题,暂时没有使用,后面可以作为技术升级来迭代。

# 新模版的处理

代码上线后,需要新增一套站点模版,由于环境已经搭建完毕,再添加新皮肤就很方便了。

# 模版中添加插件库

这个与框架无关,是因为现在的页面中,需要有插件库,否则很难迭代,就如一个小功能需要改数个文件,多多测试,新建插件库后即可避免。