再谈前后端分离开发和部署

4,541 阅读7分钟

前后端分离开发已成为业界的共识,但分离的同时也带来了部署的问题。传统web模式下,前端和后端同属一个项目,模板的渲染理所当然由后端渲染。然而随着node的流行,以及webpack的模块化打包方案,让前端在开发阶段完全有能力脱离后端环境:通过本地node启动一个服务器,搭配Mock数据,马上就可以进行业务开发了。

但是到了部署阶段,问题也就显现出来:前端最后打包出来的js,css以及index.html,到底放在哪里?静态文件js,css或者图片,我们还可以在CI阶段上传到cdn服务器上,但是最后的html模板index.html一定需要放在一个服务器上,然而这个服务器到底由前端还是后端维护?

前端维护HTML

如果html模板由前端维护,那么前端就需要自己使用一个静态服务器:提供HTML的渲染和API接口的转发。常见的单页应用,也是推荐使用Nginx进行部署。

使用Nginx部署,这里又分两种情况:

  • 静态资源完全由Nginx托管,也就是js,css和index.html放在同一个dist目录里。在这种情况下,webpack的publicPath一般不用特别设置,使用默认的/即可。
  • 静态资源上传CDN,Nginx只提供index.html。在这种情况下,webpack的publicPath要设置成cdn的地址,例如://static.demo.cn/activity/share/。但这又会引发一个问题,由于qa环境,验证环境,生产环境的cdn地址通常不同,为了让index.html可以引入正确的静态文件路径,你需要打包三次,仅仅为了生成三份引用了不同路径的html(即使三次打包的js内容完全一样)

nginx配置

server {
    listen       80;
    server_name  localhost;

    location / {
        root   /app/dist; # 打包的路径
        index  index.html index.htm;
        try_files $uri $uri/ /index.html; # 单页应用防止重刷新返回404,如果是多页应用则无需这条命令
    }

    location /api {
        proxy_pass https://anata.me; #后台转发地址
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   X-Real-IP         $remote_addr;
    }
}

理论上qa,yz,prod环境的接口转发地址也不同,因此你还需要三份nginx.conf配置

后端维护HTML

很多情况下,我们需要渲染的页面带上后端注入的动态数据,又或者页面需要支持SEO,这种情况下,我们只能把模板交给后端渲染。那么后端维护的html模板怎么获取打包后的hash值呢?

  • 前端打包后的index.html直接发给后端(简单粗暴,并不推荐)
  • 前端打包时通过插件webpack-manifest-plugin后生成一个manifest.json文件,该文件其实是个key-value的键值对,key代表了资源名称,value记录了资源的hash
{
  "common.css": "/css/common/common-bundle.804a717f.css",
  "common.js": "/js/common/common-bundle.fcb76db9.js",
  "manifest.js": "/js/manifest/manifest-bundle.551ff423.js",
  "vendor.js": "/js/vendor/vendor-bundle.d99dc0e4.js",
  "page1.css": "/css/demo/demo-bundle.795bbee4.css",
  "page1.js": "/js/demo/demo-bundle.e382801f.js",
}

后端的index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>demo</title>
  <link href="<%= manifest['page1.css']%>" rel="stylesheet">
</head>

<body>
  <h1>demo</h1>
  <script src="<%= manifest['page1.js'] %>"></script>
</body>
</html>

后端通过读取这个json文件,就可以动态渲染出文件的引用路径。

如果你曾经用过百度的打包工具FIS,它最后打包产出的map.json就是类似的资源文件列表。

使用这种方法还有一个好处:前面我们说过,如果文件上传至cdn,那么前端维护的html可能需要打包三次,因为不同环境的cdn地址不同。现在html交给后端维护了,那么这个问题就很好解决,前端只需要打包一次,不同环境的cdn地址可以让后端动态拼接生成。

当然,使用这种方法也会带来一个问题,这个json文件,后端怎么获取?

  • 把这个json文件和其他静态资源一起打包上传到cdn上,后端服务器每次启动时,先到cdn上获取这个json文件,然后存到内存里
wget --tries=3 --quiet -O manifest.json http://static.demo.cn/demo/manifest.json?`date +%s` ## 防止缓存

方案的优点:简单方便,每次前端打包,manifest.json就会自动更新,上传到cdn同时覆盖前一个版本。 方案的缺点:如果manifest.json更新了,后端则需要重启服务以便获取新的配置,当集群多的时候,重启耗费的代价可能很大。

  • manifest.json的内容放在配置中心里,后端则需要接入配置中心。每次CI打包后,调用配置中心更新接口,后端就能自动获取最新的配置。

在我平时工作项目中,这两种方案均有实现。

Node中间层

使用Nginx部署时,为了解决跨域问题,我们一般需要配置proxy_pass指向提供api的后端服务。

后端采用了SOA,微服务的架构时,proxy_pass指向的api服务器,其实本质也是一个转发服务。

前端ajax请求

// 获取商品列表
ajax.get('/api/queryProductList')

// 获取价格列表
ajax.get('/api/queryPriceList')

Nginx转发

location /api {
    proxy_pass https://demo.com; #后台转发地址
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   X-Real-IP         $remote_addr;
}

接口转发到

  1. https://demo.com/api/queryProductList

  2. https://demo.com/api/queryPriceList

查询商品列表和查询价格列表其实是由两个不同的soa服务提供:

查询商品: product.soa.neko.com 查询价格: price.soa.neko.com

因此,本质上https://demo.com这个服务也只是用来转发接口,同时对数据做部分的组装。那么这个服务,就可以用Node中间层来替代。使用了Node中间层,模板的渲染也可以从Nginx转移到Node了。

当然,多了一层Node,对于前端的综合要求也随之提高,后端的部署,监控,日志,性能等等问题也随之而来,全(干)工程师应运而生。

工作现状

我司大部分to C的前端项目,都是采用Node层渲染模板加转发接口的开发模式,还有少量项目采用Java tomcat渲染html模板。

大部分页面都是多页应用,并不是典型的单页应用。

Node层渲染模板,又分两种情况:

  • 需要支持SEO,则采用传统的模板渲染,填充展示数据。但是JS的业务代码,依旧前后端分离,并不在Node项目里。这类页面,一般都是采用Jquery+webpack模块化打包。
  • 不需要支持SEO,则Node只渲染一个空html模板,页面内容完全由JS生成。这类页面,一般采用最新的前端MVC框架,比如Vue和React。

当然近几年比较流行的SSR方案,让Node渲染模板时可以直接使用Vue和React的同构组件,直出页面后,用户的交互体验又如单页应用般流畅,只能说:历史总是惊人的相似。

从某种程度上说,SSR是一种向传统模式的回归,不过这种回归并不是倒退,而是一种螺旋式的发展。

实战

理论知识讲了那么多,现在我们来实战一下。在上一篇文章里,我介绍了webpack多页打包的原理,同时搭建了一个简单的webpack4-boilerplate。这个模板只是一个前端开发模板,其实它还对应着一个node后端模板koa2-multipage-boilerplate

这个node项目最重要的就是实现了前面说的:如何读取manifest.json文件,动态渲染静态文件的引用路径,从而前后端分离开发和部署。

详情见chunkmap.js这个koa2中间件的源码。

const chunkmap = require('./chunkmap');
app.use(chunkmap({
  staticServer: '//0.0.0.0:9001',
  staticResourceMappingPath: './mainfest.json'
}));

这个中间件接受两个参数

  • staticServer:静态资源服务器地址,本地开发时,填写的就是webpack4-boilerplate这个前端项目启动的服务器。到了qa,产线时,则填写真正的cdn地址
  • staticResourceMappingPath: 资源映射文件路径,也就是manifest.json文件

本地开发时的manifest.json,不带hash值

{
  "home.css": "/css/home/home-bundle.css",
  "home.js": "/js/home/home-bundle.js",
}

打包后的manifest.json,带hash值

{
  "home.css": "/css/home/home-bundle.d2378378.css",
  "home.js": "/js/home/home-bundle.cb49dfaf.js",
}

使用了这个中间件后,koa的ctx.state全局变量上就带有一个bundle属性,里面的内容是:

{
  "home.css": "//0.0.0.0:9001/css/home/home-bundle.d2378378.css",
  "home.js": "//0.0.0.0:9001/js/home/home-bundle.cb49dfaf.js",
}

然后通过模板引擎,动态渲染出实际页面。当然你也可以在页面中动态生成展示内容,从而支持SEO。

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <title><%= title %></title>
  <link href="<%= bundle['home.css']%>" rel="stylesheet">
</head>

<body>
  <div id="app"></div>
  <script src="<%= bundle['home.js']%>"></script>
</body>
</html>

总结

前后端分离带来了工作效率上的提高,Node中间层则给前端打开了一条走进后端的道路。当然机遇总是与挑战并存,在前端技术日新月异的今天,真想说一句:老子学不动了!

参考

大公司里怎样开发和部署前端代码?