网易智慧企业Node.js实践(1) : Node应用架构设计和React同构

avatar
开发 @网易智企
近期网易智慧企业在 Node.js(以下简称 Node) 的接入上已输出阶段性成果,特推出此系列文章,希望能与大家分享部分接入过程的方案,从而提供帮助。系列主要包括以下内容。

1. Node 应用架构设计

2. React 同构

3. 健康检查和平滑发布

4. 前端代码上CDN、代码发现

5. 应用监控

6. 灰度环境

本文作为系列文章的第一篇主要介绍网易智慧企业 Node 从0到1的接入过程,主要涉及 Node 的应用架构和同构渲染,也就是1、2这两部分。后续会分享关于 Node 工程实践相关内容(3、4、5、6)。

关于 Node

Node 是一个基于 Chrome V8 引擎的 JavaScript 运行时。它诞生于2009年,Node 第一次把JavaScript带入到后端服务器开发,另外还可以通过它编写工具,比如代码打包工具,但是它诞生的最初目的还是为了实现高性能 Web 服务器。它内部实现的异步 IO、事件驱动就是为高性能 Web 服务而生的。

经过过去这么多年发展,Node 已经形成了非常成熟的应用模式,比如:BFF(Back-end For Front-end)——服务于前端的后端,把 Node 作为后端的一层,专门为前端提供数据裁剪和格式化、聚合编排等功能。另外还有最近非常火热的基于 Node 实现的 Serverless 服务。那么具体到我们智慧企业是怎么使用 Node 的呢?那就首先介绍下我们的需求背景。

需求背景

2019年底网易智慧企业正在打造一款 SCRM 产品—网易互客(https://huke.163.com),它最初主要有3块需求:

1、互客平台。

2、互客运营系统(内部使用)。

3、互客官网。

前两部分对交互要求比较高,有一些需求决定技术上需要优先使用单页应用的形式。官网又是对 SEO 有需求的,所以需要有同构渲染的能力(前端使用 React 框架);另外鉴于目前的技术架构对开发效率的提升已经形成瓶颈,所以考虑使用新的技术方案,来完全解放前后端的生产力,最终考虑使用 Node 来实现前后端的完全分离,彻底解决之前前端要写 Java 模版文件和前后端对页面数据理解不一致尴尬局面。

决定使用 Node 后,首先要解决的问题是如何和 Java 端配合,也就是新的前后端分工,鉴于这是我们第一个对外服务的 Node 项目,作为初次的尝试,我们考虑使用渐进式开发模式,先从接进来开始做,所以我们初始给 Node 分配的任务比较简单,包括:

1、页面渲染。

2、用户登录校验。

3、页面初始必要数据填充。

4、功能型接口实现。

另外还有一个目标是通过这个项目,逐步完善智慧企业的 Node 工程工具体系,最终形成智慧企业自己的 Node 生态。

设计和实现

确定了如何和 Java 端的配合后,另一个问题是选择 Node 框架,经过调研,我们选择了 Egg.js 作为 Node 框架方案,选它的原因是因为它应该是目前国内使用最为广泛,生态最为完善的 Node 企业级框架。任务分工和框架都定下来之后我们应用的整体架构也就出来了,如下图:
架构图

简单介绍下一个完整的用户请求的访问路径。首先用户请求到网关,网关根据 URL 转发规则转发到 Node 或者 Java 应用,从而完成一次页面访问或接口请求。这里面涉及到路由的设计,页面和接口的 URL 要能够通过 path 区分。

拿我们的客户列表页面举例,客户列表的 URL 的 path 是 `/admin/customer/all`,我们的规则是 `/admin*` 对应页面请求,所以请求会被网关转发到 Node 上,在 Node 中使用 HTTP 请求从 Java 端获取页面初始数据,放入页面模版,返回给用户,完成页面访问请求。

另外一个比较重要的问题是用户的登录信息,我们使用了比较偏传统的方案,用户登录功能在 Java 端实现,当用户访问页面时,Node 会检查 cookie 里的登录 token,并进行校验,如果 token 不存在或不正确,就给用户 redirect 到登录页面,当用户填写完信息点击登录按钮时,调用 Java 端的登录接口进行登录,成功后 Java 端会给登录请求的响应带上 cookie ,这样前端、Node 端、Java 端的登录信息就能串起来。

当然这些只是 Node 作为页面服务提供的能力,但是我们还需要 React 的同构能力。

关于同构

一套代码既可以在服务端运行又可以在客户端运行,在服务器端执行一次,用于实现服务器端渲染,在客户端再执行一次,用于接管页面交互,这就是同构应用。简而言之, 就是服务端直出和客户端渲染的组合, 能够充分结合两者的优势,并有效避免两者的不足。

同构不仅仅能解决前面说的 SEO 问题,它还能有效缩减页面白屏时间,因为它能把之前的三次串行的 HTTP 请求缩减为一次,而白屏时间对用户的影响也是非常大的。

一般前端框架是需要对 DOM 进行操作的,在浏览器环境当然没有问题,而在Node 是没有 DOM 这个概念的,那 React 是如何实现在 Node 端进渲染的呢?这因为 React 中引入的虚拟 DOM,虚拟 DOM 是真实 DOM 的一个 JavaScript 对象映射,React 在做页面操作时,实际上不是直接操作 DOM,而是操作虚拟 DOM,也就是操作普通的 JavaScript 对象,这就使得 SSR 成为了可能。在 Node 端 React 把虚拟 DOM 输出为字符串,而在浏览器端 React 把虚拟 DOM 映射为真实 DOM,完成页面渲染。

那么如何在 Node 端把 React 页面渲染为字符串呢?React 框架提供了4个API针对不同的使用场景,分别是:
* renderToString()
* renderToStaticMarkup()
* renderToNodeStream()
* renderToStaticNodeStream()

结合需求我们选择 `renderToString` 方法。

其实整个服务端渲染的逻辑非常简单,把初始数据传给 React 组件使用 `renderToString` 进行渲染,得到一个字符串,把字符串放入页面模版中的 React 挂载节点内就行了。但是要实现一个能根据路由自动渲染对应的组件的 Egg.js 插件还是有一点复杂的,所以我们实现了 `pp-fishssr` 服务端渲染插件,以满足根据路由渲染对应页面的需求。

主要介绍下我们的实现的不一样的地方,首先是配置方式:

```json
fishssr: {
	routes: [
	{
	  path: ‘/admin/*’,
	  Component: () => (require(‘@/page/admin’).default),
	  controller: ‘page.admin’
	},
	{
	  path: ‘/user/*’,
	  Component: () => (require(‘@/page/user’).default),
	  controller: ‘user.h5Page’,
	},
	],
	// 页面模版文件路径
	template: ‘screen/index.html’,
	// 服务端渲染打包后的js文件
	serverJs: resolvePath(‘dist/Page.server.js’),
}
```
介绍配置项:

path: `/admin/*`、`/user/*` 分别对应了一个单页应用。

Component: 对应了页面的 React 组件,内部会处理初始数据,转化为store 的 preloadedState 或 props,里面使用前端路由。

controller: 对应的是 Egg.js 中的 controller,用来获取页面初始数据,然后使用`this.ctx.fishssr.renderPage(initData)`实现页面渲染。

template: 页面的模版文件,内部 `stream` 就是 Node 渲染 React 页面组件之后得到的字符串,文件的内容大致如下:

```html
<!DOCTYPE html>
<html lang=‘zh-CN’>
<head>
  <title>网易互客</title>
  <link rel=‘stylesheet’ href=‘/css/Page.css’ />
</head>
 
<body>
  <div id=‘app’>
    {{stream | safe}}
  </div>
	<script>
  window.__INITIAL_DATA__ = {{ initialData | safe}};
  </script>
  <script src=‘/js/runtime~Page.js’></script>
  <script src=‘/js/Page.js’></script>
</body>
</html>
```
 
serverJs :是页面入口文件对应的 Node 端打包版本,入口文件主要代码如下:
```
const clientRender = async () => {
  ReactDOM.hydrate(
    <>
      {
        Routes.map(route => {
          const { path, Component } = route
          const isMatch = matchPath(window.location.pathname, route)
          if ( !isMatch ) {
            return null
          }
          const ActiveComponent = Component()
          const WrappedComponent = GetInitialProps(ActiveComponent)
          return <WrappedComponent key={path} />
        })
      }
    </>, document.getElementById('app'))
}
 
const serverRender = async (params) => {
  const { initData, path, url } = params
  const ActiveComponent = getComponent(Routes, path)()
  return (
    <StaticRouter location={url} context={initData}>
      <ActiveComponent {... initData} />
    </StaticRouter>
  )
}
 
export default __isBrowser__ ? clientRender() : serverRender
```
 
这段代码会根据路由渲染对应的页面组件,同时根据不同打包环境输出对应 Node 端和浏览器端的渲染代码。

总结

Egg.js 作为一个完备的企业级 Node 框架,在接入过程中可以说非常顺滑,主要精力放在解决自身业务需求和后端配合即可。

目前使用这个方案的产品**网易互客**已经上线,这个方案解决了文章开头所说技术和业务需求的,同时它带来的新的前后端配合模式也极大的提高了不仅仅是前端的开发效率,对后端来说也是非常友好的。同时前端也可拓宽自己边界,能够承接更多需求,比如我们运营系统、功能性 API,比如微信 JS-SDK 认证,之前只能放在后端,现在放在 Node 端,前端开发起来更加灵活,减少很大的沟通成本。但是目前作为对外服务 Node 应用只有这些还是不够的,还是需要很多工程工具的支持。

后续我会介绍我们在 Node 工程上的一些实践,让 Node 应用更稳定的提供服务、以及更快更方便的排查问题。

更多技术干货,欢迎关注vx公众号“网易智慧企业技术+”。系列课程提前看,精品礼物免费得,还可直接对话CTO。

听网易CTO讲述前沿观察,看最有价值技术干货,学网易最新实践经验。网易智慧企业技术+,陪你从思考者成长为技术专家。