前端微服务在晓黑板的落地 | 🏆 技术专题第四期征文

677 阅读7分钟

作者:梅瑀
本文默认您了解微服务的基础概念和原理,讲述了前端微服务架构在晓黑板的落地细节以及相关思考。

业务背景和技术选型

晓黑板是一个集家校沟通和教学平台于一体的大一统app。涵盖消息、班级、私聊、个人中心、应用中心、晓书包、晓成长、直播等几大模块。每一个功能都比较复杂、而且页面繁多。团队并行开发人数达14人最多。面对这样的庞大应用,从长远的维护角度来看,传统的架构已经不太合适。按照以往的做法,后面会出现代码规模极度膨胀,上线分支合并困难,甚至打包速度极度下降等非常不符合项目快速迭代节奏的困难。所以经过慎重考虑,微服务框架的独立仓库、独立部署等优点正是我们需要的。所以我们决心引入微服务架构。

xiaoheiban

选择框架

我们重点考察了两种框架 single-spa 和基于前者的 qiankun。出于极简原则和后面的可控性考虑,我们最终选择single-spa和基于此开发周边的工具。

落地

第一个版本上线前后开发了1个月,迄今为止迭代了3个月。配合运维集成gitlab的CI/CD,做到了一键部署。开发效率和体验上相对于之前的经历有了非常直观的提升。

  • 切分仓库和路由划分

    把整个项目按功能分散到各个仓库,独立开发,独立部署,减少后面维护的成本。我们是按照主导航的维度去切割项目。开始由于个别模块太过庞大,把模块切分细了,导致后面数据交互代码复用非常麻烦。后来还做了一次合并,最终确定了只按主导航去做划分,打到一个平衡。总结下来:尽量以业务相关性的维度去划分子服务,太粗导致代码仓库过于集中达不到切分的目的,太细代码复用数据通信都会有不同程度的麻烦。需要根据实际情况来做划分。

  • 开发环境的搭建

    最初,我们把整个项目部署到本机进行开发。发现过于繁琐。实际上官方给了一个非常方便的工具 import-map-overrides。先把整个项目在一个内网机器上部署好。打开内网地址,在localStorage里面手动加上devtools为true,刷新页面就能在右下角开启模块复写的功能。

    import-map-overrides import-map-overrides

    例如,开发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规范质量。用统一的工具处理开发打包问题。子服务的划分至关重要,需要根据业务属性和数据相关性因地制宜。如果子服务间公共代码过多,且不好合并的时候。微服务显得比较鸡肋。

🏆 技术专题第四期 | 聊聊微前端的那些事