作者:梅瑀
本文默认您了解微服务的基础概念和原理,讲述了前端微服务架构在晓黑板的落地细节以及相关思考。
业务背景和技术选型
晓黑板是一个集家校沟通和教学平台于一体的大一统app。涵盖消息、班级、私聊、个人中心、应用中心、晓书包、晓成长、直播等几大模块。每一个功能都比较复杂、而且页面繁多。团队并行开发人数达14人最多。面对这样的庞大应用,从长远的维护角度来看,传统的架构已经不太合适。按照以往的做法,后面会出现代码规模极度膨胀,上线分支合并困难,甚至打包速度极度下降等非常不符合项目快速迭代节奏的困难。所以经过慎重考虑,微服务框架的独立仓库、独立部署等优点正是我们需要的。所以我们决心引入微服务架构。
选择框架
我们重点考察了两种框架 single-spa
和基于前者的 qiankun
。出于极简原则和后面的可控性考虑,我们最终选择single-spa和基于此开发周边的工具。
落地
第一个版本上线前后开发了1个月,迄今为止迭代了3个月。配合运维集成gitlab的CI/CD
,做到了一键部署。开发效率和体验上相对于之前的经历有了非常直观的提升。
-
切分仓库和路由划分
把整个项目按功能分散到各个仓库,独立开发,独立部署,减少后面维护的成本。我们是按照主导航的维度去切割项目。开始由于个别模块太过庞大,把模块切分细了,导致后面数据交互代码复用非常麻烦。后来还做了一次合并,最终确定了只按主导航去做划分,打到一个平衡。总结下来:尽量以业务相关性的维度去划分子服务,太粗导致代码仓库过于集中达不到切分的目的,太细代码复用数据通信都会有不同程度的麻烦。需要根据实际情况来做划分。
-
开发环境的搭建
最初,我们把整个项目部署到本机进行开发。发现过于繁琐。实际上官方给了一个非常方便的工具
import-map-overrides
。先把整个项目在一个内网机器上部署好。打开内网地址,在localStorage里面手动加上devtools为true,刷新页面就能在右下角开启模块复写的功能。例如,开发A模块,先在本机
webpack-dev-server
打包出A模块的地址。然后打开内网开发地址将模块地址改成本机。就能方便地进行开发了。 -
公共模块和样式隔离
假如你有一个组件库,可供每个子服务去使用。如果每个服务都打包出一份这个组件显然不太合适。推荐的做法是:在webpack打包时externals排除这个组件库。然后将这个组件包打成
umd
格式或者systemjs
格式,当作一个子服务配置在全局的import map
里面。当然为了在开发时能获取完整的ts语法提示功能。仍然需要把组件包发布到npm私库上。各个模块的样式隔离,我们则直接采用了
css modules
,由于css modules
是以文件路径做hash,需要加上项目名称作为prefix
防止冲突。 -
数据原则
为了避免对数据通信方案的争论不休。我们结合官网的guide,总结了几点原则
- 不变需要共享的数据 localStorage
- 不需要共享,但需要保存在内存的数据。写在组件外,最后打包会自动处理成闭包的形式。
- 一些id不变、接口返回结果肯定不变的数据,在接口层做cache
- 开发一个顶层的redux store,然后通过参数传给各个应用。
由于子服务划分的较为合理,最后发现需要放在顶层
redux store
里的数据只有一个用户名。 -
相关脚手架的开发和集成CI CD
为了简化子服务初始化,开发、打包问题,我们产出了一个脚手架来一键解决问题。大概命令如下示:
xfe [create | dev | build] [-e env-path] [-c your-additional-wepackcofig]
这里我们贴一下webpack的子服务的通用配置,供参考
entry: path.resolve(folder, './src/index.ts'), output: { path: path.resolve(folder, './dist'), filename: '[name].js', publicPath: `/${appName}/`, libraryTarget: 'system', jsonpFunction: `wbJsonp${appName}` }, module: { rules: [ { parser: { system: false } }, { test: /\.(ts|tsx)$/, exclude: /node_modules/, use: [ 'thread-loader', { loader: 'ts-loader', options: { happyPackMode: true } }], }, { test: /\.(css|less)$/, exclude: /node_modules/, use: [{ loader: MiniCssExtractPlugin.loader, options: { hmr: process.env.NODE_ENV === 'development' } }, { loader: 'css-loader', options: { modules: { localIdentName: '[path][name]__[local]--[hash:base64:5]', hashPrefix: appName, }, url: true, }, }, { loader: 'postcss-loader', options: { plugins: [ autoprefixer({ overrideBrowserslist: ['last 15 versions'], }), ], }, }, { loader: 'less-loader', }], }, { test: /\.(png|jpg|gif)$/i, use: [ { loader: 'url-loader', options: { limit: 5120, esModule: false, }, }, ], }, { test: /\.svg$/, use: [{ loader: 'file-loader' }], }], }, plugins: [ new MiniCssExtractPlugin({ filename: '[name].[chunkhash].css', }), new CleanWebpackPlugin() ], externals: ['react', 'react-dom', 'redux', 'react-redux', 'react-router-dom', '@xhb/utils', '@xhb/components']
各个模块的jsonpFunction必须不一样,webpackJsonp为window全局变量用于加载个模块的子js,不做区分的话,加载慢的包会覆盖加载快的包。上文提到的externals用于各个子模块排除公用的包。ts-loader引入thread-loader,会显著提升构建速度,我们测试下来能提升40%的构建速度
在运维的帮助下,我们各个子服务的接入了CI CD,在提交dev test pre master的时候,会触发钩子自动构建发布到对应环境。各个子模块独立开发,独立发布。在引入
thread-loader
每个上线过程均在2min内。跟相类似的项目的30min+有了大幅提升。而且由于仓库独立,上线前夜没有太多痛苦的代码合并冲突的过程。开发体验丝滑无比。有点麻烦的是将每个子服务发布后自动更新
import-map
也接入CI/CD。由于之前业务重心在于客户端。客户端直接将所有静态文件pack包里,所以现阶段web端的import-map
是写死在html里。导致各个子服务必须保持每次构建后的主文件一致。所以nginx
上静态文件缓存只能配置为304协商缓存,不能采用性能更好的hash强缓存。如果需要启用hash强缓存的话,可以将
import-map
放在一个独立git上面。每次CI结束后触发钩子将构建结果提交到import-map
对应分支来触发新的CD流程即可。为nginx对import-map
不做缓存或者启用协商缓存。子服务相关带hash的静态文件可作为强缓存。截止到发文时间,我点开了gitlab的ci/cd数据,我们分了10个仓库,大部分仓库的CICD在各个环境的CICD次数都在100-200,个别仓库分别达到了700+和1000+,不禁联想下,如果全都集中在一个仓库,面对如此迅速的变更速度,合代码和检查bug该是怎么样的一种噩梦。
总结
微服务对于多人并行开发、大型项目高速迭代有着良好的支持。完全不适合小型或者“一次性”项目。需要统一规范一下开发模版,引入lint规范质量。用统一的工具处理开发打包问题。子服务的划分至关重要,需要根据业务属性和数据相关性因地制宜。如果子服务间公共代码过多,且不好合并的时候。微服务显得比较鸡肋。