ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Java后端与Vue的微前端架构:业务系统拆分与集成平台设计与实践

基于Java后端与Vue的微前端架构:业务系统拆分与集成平台设计与实践 很多团队在业务系统越做越大的时候都会撞上同一堵墙代码仓库膨胀、前端模块互相纠缠、发版要全员陪着熬夜、想给某个子系统单独扩容却找不到边界。我去年带着团队把一个单体后台管理系统拆成了基于Java后端与Vue前端的微前端架构并在此基础上搭建了业务系统拆分与集成平台。整个过程弯路走了一些但沉淀下来的设计思路和代码骨架对正在做同样改造的团队应该挺有参考价值。这篇文章就把模型设计、拆分思路、集成方案和部分核心代码一次性讲透。1. 不拆不行单体系统在业务膨胀后暴露出的真实痛点先说背景。我们原先的系统的前端是一个Vue 2的单体工程后端是Spring Boot的单体服务。这个架构在业务初期非常舒服一个人能从头撸到尾联调成本低部署也简单。但等业务线从两条变成六条前端代码超过20万行后端模块互相调用形成网状依赖之后问题就变成了一颗定时炸弹。最典型的表现是发版效率直线下滑。前端任何一个小模块改了样式整个系统都要重新构建构建一次要跑十几分钟发布窗口从下午两点开始搞到晚上八九点是常态。后端更麻烦订单模块一个慢SQL拖垮了全站的接口但那个出问题的查询是三个月前别的小组写的现任负责人都说不清那段逻辑。还有一个隐性成本是技术升级被锁死我们想给某个报表模块引入Web Worker优化大数据量渲染但因为模块之间共享了全局状态和大量全局组件谁都不敢轻举妄动。这些问题单靠管理手段解决不了纯粹是架构层面把不该耦合的东西耦合在一起了。我当时在技术方案评审时给管理层算了一笔账系统每延迟发布一次业务方就少一周时间做市场活动每一次全量回归测试光人工成本就在四位数以上。结论很明确系统必须拆。但拆分也有两种路线。一种是后端做微服务前端继续一坨另一种是前后端一起拆前端走微前端。只拆后端不拆前端的问题在于前端还是会因为业务模块太多而持续膨胀而且后端拆成微服务之后前端的调用关系更复杂了单体前端反而成了新的瓶颈。所以团队最终拍板的是前后端联动拆分前端用微前端架构后端按业务域拆服务中间通过统一的网关和注册中心打通。这里需要澄清一个容易混淆的概念微前端拆的不是页面而是业务模块的边界。如果你只是把路由拆成几个懒加载的组件那不叫微前端那叫代码分割。真正意义上的微前端是让每个业务子应用拥有独立的开发、构建、部署生命周期子应用之间在运行时互不阻塞。这也是我们选择qiankun作为基座框架的核心原因它把JS沙箱、样式隔离、生命周期管理这些脏活累活都封装好了团队上手成本低。2. 架构设计平台由哪些核心模块组成各自承担什么职责拆分与集成平台的整体架构我画过不下十版最终落地的方案是四个核心模块加一条贯穿始终的链路。四个模块分别是基座主应用、业务子应用、后端拆分服务和集成编排层。这条链路就是用户从浏览器发起的每一次请求在主应用、子应用、后端服务之间的流转路径。2.1 基座主应用负责承载和调度不负责具体业务基座主应用的定位是壳它只做三件事加载子应用、管理公共登录态、维护全局导航骨架。我见过不少团队把公共业务逻辑也塞进基座比如把订单列表页直接写在主应用里这是非常糟糕的做法。基座一旦膨胀就会重新变成一个大泥球。我们在基座里预留了路由注册表每个子应用通过registerMicroApps注册之后主应用会把子应用的activeRule与导航菜单做映射。这样用户从菜单点击进入子应用时实际上触发了主应用的路由变化进而唤起了子应用的挂载。这里还要注意一个细节基座不能依赖任何子应用的私有依赖它的package.json要尽量干净。我们基座只引入了Vue、qiankun和一套公共UI组件库其他东西全都不装。2.2 业务子应用按业务域切分独立仓库独立部署子应用的划分遵循高内聚低耦合原则。我们把订单、商品、用户、营销、报表、权限这六个大域拆成了六个Vue子应用每个子应用拥有独立仓库、独立package.json、独立的构建产物。子应用之间不允许直接互相引用组件或工具函数如果确有需要必须通过主应用提供的公共依赖进行。子应用在qiankun架构里有两种运行模式一种是构建时打包成umd格式的单一bundle还有一种是用webpack的externals配合运行时加载。我们用的是前者简单直接每个子应用build之后产出一个入口JS文件和一个CSS文件基座通过import-html-entry去拉取并执行。实际测试下来首屏性能比原先的单体应用反而好因为子应用只在路由命中时加载天然做了按需加载代价是第一次进入某个子应用时有一小段白屏时间这个可以通过骨架屏优化。2.3 后端拆分服务与集成编排层Java端如何配合前端拆分后端不能假装看不见前端的拆分否则前后端边界就错位了。我们沿着前端的六个业务域把原有的Spring Boot单体服务拆成了六个独立部署的Java服务服务之间通过OpenFeign做内部调用所有外部请求统一经过Spring Cloud Gateway网关进行路由转发。集成编排层是整个平台设计里最容易被忽视的部分。它的职责是处理跨域查询和数据聚合。比如用户管理页面需要展示用户的订单数量这个数据在订单服务里但页面挂在用户子应用下。我们没有让前端跨两个服务去请求而是在网关后面加了一层BFF层用Java的CompletableFuture并发调用两个服务聚合成一个完整的响应返回给前端。BFF层同时承担了响应裁剪和字段映射的工作前端拿到的是刚刚好够用的数据不多不少。2.4 数据模型与接口模型的设计思路拆分后的接口设计要有一套统一的规范来兜底。我们约定所有子应用的接口路径统一以业务域开头比如/order/、/user/、/product/网关层按前缀做路由转发。参数对象和返回结构也做了统一封装返回体固定为code、message、data三段式结构分页参数固定为pageNum和pageSize时间字段统一用时间戳传输由前端统一格式化。数据库层面的拆分没有一步到位而是分了三期。第一期只拆分逻辑边界物理上还是共库第二期按核心交易域和支撑域分库订单、商品独立成库第三期才把报表类的大查询迁到独立只读从库。这样分期的好处是控制风险每一期都能独立上线验证。现在回头看如果一开始强拆物理库光是数据迁移和双写就够我们喝一壶的。3. 微前端集成的核心实现qiankun基座下的主应用与子应用落地代码理论说完了直接进入代码。这部分我尽量还原真实项目里的写法去掉花里胡哨的封装保留最关键的骨架。你能跑通这一套就已经具备了独立搭建微前端平台的基本盘。3.1 主应用注册子应用的完整配置先从主应用侧开始。安装依赖这一步不过多赘述直接看main.js里的初始化逻辑。我们用的是qiankun 2.x版本注册和启动的API非常稳定。// src/main.js import { registerMicroApps, start, initGlobalState } from qiankun; // 注册表所有子应用的元信息都写在这里 const apps [ { name: order-app, entry: process.env.VUE_APP_ORDER_ENTRY || //localhost:5001, container: #subapp-viewport, activeRule: /order, }, { name: user-app, entry: process.env.VUE_APP_USER_ENTRY || //localhost:5002, container: #subapp-viewport, activeRule: /user, }, { name: product-app, entry: process.env.VUE_APP_PRODUCT_ENTRY || //localhost:5003, container: #subapp-viewport, activeRule: /product, }, ]; // 全局状态登录态、用户信息、全局配置 const actions initGlobalState({ token: localStorage.getItem(token), userInfo: null, }); // 监听主应用状态变更通知所有子应用 actions.onGlobalStateChange((state, prev) { console.log(主应用状态变更, state); }); // 注册并启动 registerMicroApps(apps, { beforeLoad: [app { console.log(加载前, app.name); }], beforeMount: [app { console.log(挂载前, app.name); }], afterUnmount: [app { console.log(卸载后, app.name); }], }); start({ sandbox: { experimentalStyleIsolation: true } });几个关键点给你们划一下。容器id是#subapp-viewport每个子应用挂载前会替换这个容器的内容所以这个div不要放任何静态内容。activeRule要跟主应用的路由前缀对齐主应用用history路由模式时activeRule匹配的是路径前缀。如果子应用挂在二级路径下activeRule要把完整前缀写上比如/crm/order。3.2 子应用改造Vue工程如何暴露微前端生命周期子应用侧最关键的是改造入口文件main.js让它暴露qiankun要求的三个生命周期bootstrap、mount、unmount。我们用的是vue-cli创建的标准工程改造并不复杂。// 子应用入口 src/main.js import Vue from vue; import App from ./App.vue; import router from ./router; import store from ./store; Vue.config.productionTip false; let instance null; // 渲染函数负责用当前路由创建Vue实例 function render(props {}) { const { container } props; instance new Vue({ router, store, render: h h(App), }).$mount(container ? container.querySelector(#app) : #app); } // 独立运行时的引导逻辑用于本地开发 if (!window.__POWERED_BY_QIANKUN__) { render(); } // 导出微前端生命周期 export async function bootstrap() { console.log(子应用启动); } export async function mount(props) { render(props); } export async function unmount() { instance.$destroy(); instance null; }这里有一个非常容易踩的坑是container参数。子应用被基座加载时Vue实例不能再挂到自己的#app上必须挂到基座传入的container.querySelector(#app)上。如果漏了这个子应用会挂载到主应用的body上轻则DOM错乱重则样式污染全站。3.3 子应用路由与打包配置的联动路由和打包配置需要成对改。vue-router要设置base而且必须读取基座传入的前缀否则跳转会迷路。webpack打包要开启umd格式和publicPath动态设置。// 子应用 src/router/index.js import Vue from vue; import Router from vue-router; Vue.use(Router); // 关键base要取主应用路径前缀qiankun会通过window.__routerBase__注入 const router new Router({ mode: history, base: window.__POWERED_BY_QIANKUN__ ? window.__routerBase__ : process.env.BASE_URL, routes: [ { path: /, name: Home, component: () import(../views/Home.vue) }, { path: /list, name: List, component: () import(../views/List.vue) }, ], }); export default router;打包配置在vue.config.js里改重点是output.library和libraryTarget。libraryTarget必须是umdlibrary这个值最好是全局唯一避免主应用同时加载多个子应用时变量名冲突。// 子应用 vue.config.js const { name } require(./package.json); module.exports { publicPath: /, // 服务器相对路径 devServer: { port: 5001, headers: { Access-Control-Allow-Origin: *, // 开发环境必须允许跨域 }, }, configureWebpack: { output: { library: ${name}-[name], libraryTarget: umd, jsonpFunction: webpackJsonp_${name}, }, }, };配置文件里有不少细节值得单独说明。devServer的跨域头必须配否则基座所在的端口跟子应用端口不一致时浏览器会直接拦截样式文件和脚本文件的加载。jsonpFunction这行是为了防止多个webpack应用同时运行时异步加载chunk的jsonp函数重名。生产环境的服务器也要给子应用入口文件配好CORS头NGINX里加三行配置就能搞定别等到上线了才想起来排查一个跨域问题能磨掉你大半天。3.4 主应用与子应用之间的通信机制微前端通信是集成平台设计里最容易被问到的环节。qiankun官方提供的方案是initGlobalState它实现了一套简易的全局状态总线。我们的用法是主应用统一管理token和用户信息任何子应用需要登录态时从globalState读取子应用要跳转其他域页面时通过主应用下发跳转指令。// 子应用内使用全局通信 // 1. 在mount生命周期里接收actions let globalActions null; export async function mount(props) { render(props); globalActions props.onGlobalStateChange || null; // 监听主应用状态变化 if (globalActions) { globalActions((state, prev) { if (state.token ! prev.token) { // token刷新后同步更新业务里的请求头 localStorage.setItem(token, state.token); } }, true); } } // 2. 向主应用发起状态变更 function updateToken(token) { if (globalActions) { globalActions.setGlobalState({ token }); } }注意setGlobalState可以传部分字段qiankun会用浅合并的方式更新state所以不需要每次把整个state都传一遍。我实际开发中遇到的一个问题是初始化时序基座先执行start子应用后mount如果子应用在mount时立即setGlobalState可能主应用还没来得及initGlobalState。稳妥的做法是子应用先读取props里的字段再考虑主动更新。4. 业务系统拆分的方法论六个域怎么切、边界怎么定、公共代码怎么处理架构代码只是骨架真正的难点在怎么确定拆分边界。这块的决策直接决定后续半年你会不会天天救火。我在方案阶段做了三轮评审中途推翻过两版方案最后沉淀下来的方法论可以浓缩成三个词业务驱动、数据反推、依赖解耦。4.1 先画业务流程图再定服务边界拆解的第一步不是画架构图而是把公司的核心业务流程一条条列出来。我们从订单创建这条主链路开始画泳道图把涉及的角色、动作、数据实体全部标出来。画完之后发现订单流程跨越了库存、支付、优惠券、物流四个领域这说明订单这个域天然是聚合根它必须通过调用其他领域的服务来凑齐整个流程。边界确定的一个硬性标准是一个业务对象的一生只能由一个服务负责。比如商品的生命周期包括创建、上下架、编辑、删除、审核这些操作全部由product-service承担其他服务不能直接改商品表只能调商品服务暴露的接口。这条规则执行起来要很坚决否则拆完跟没拆没区别。4.2 依赖方向单向依赖是红线我们内部有一个依赖图审查环节每个服务只能依赖下游服务不能出现循环依赖。比如订单服务依赖用户服务和商品服务但用户服务不能反过来依赖订单服务。如果用户服务需要展示订单数量方案是通过事件订阅或者BFF聚合在接口层把两边数据拼装起来而不是让user-service直接调order-service。这个红线一旦破掉微前端拆分就名存实亡了。因为前端的子应用划分和后端服务划分是对齐的后端互相循环依赖前端必然也要互相跳转、互相共享数据最后变成一场灾难。我们在集成平台上加了一个依赖检查脚本每次代码提交时自动分析Java代码里的import关系发现跨越服务层的依赖直接拦截CI。4.3 公共模块的三种归宿拆分中最棘手的问题是公共代码。原来的单体工程里有一个common模块里面什么都有工具类、注解、枚举、DTO、常量甚至连几个HttpUtil都有。这类代码的归属我们定了三条规则第一纯工具类下沉到独立的基础库比如DateUtils、StringUtils这种打包成独立jar所有服务引用。第二业务相关的枚举和常量必须在对应的服务里定义不允许放在公共库里。比如订单状态枚举OrderStatus必须放order-service如果user-service需要用到这个枚举值做判断那就在参数上传递字符串不依赖对方包。第三实体类和DTO一概不共享。服务之间通信通过自定义的APIRequest和APIResponse对象进行这些对象挂在服务对外暴露的feign-client包里。为了让这条规则落地我们的Maven私服上禁用了对老common包的发布功能物理上堵住这条路。4.4 数据库拆分时如何保证数据一致性数据库拆分到物理分库后最大隐患是分布式事务。我们没有引入Seata这类重量级框架而是采用了一种比较务实的两阶段补偿策略。核心交易链路里订单创建和库存扣减这两个操作我们使用本地消息表加定时任务扫描的方式保证最终一致性。本地消息表的原理不复杂order-service在本地事务里同时写订单表消息表和业务表然后异步把消息推给库存服务。如果库存服务处理失败消息表里的记录会保持待重试状态定时任务每十秒拉一次未处理消息重新投递。库存服务的消费逻辑要做幂等处理我们用的是业务唯一键加消费记录表双重校验确保重复消息不会重复减库存。这种方案的优点是不用引入额外的基础设施适合中小团队。缺点是需要业务侧配合做幂等设计。如果你们公司有比较成熟的消息中间件团队也可以直接用RocketMQ的事务消息效果更好。5. 集成平台在重构中的关键作用版本管理、灰度发布与自动化部署微前端改造不只是技术重构更是一次交付方式的变革。集成平台这个模块承载的正是这一部分能力它让六个子应用、六个Java服务在持续交付上能够互不阻塞。5.1 统一版本管理Model模型如何映射到发布我们设计了一个发布模型把用户可见的版本号定义为平台版本每个平台版本对应一组子应用版本和一个后端服务版本。发布记录表有三个关键字段platformVersion、appVersionMap、serviceVersionMap。appVersionMap是一个JSON结构记录order-app1.2.3、user-app2.0.1这样的映射关系。有了这个模型之后回滚变得很干净。如果线上出问题只需要把platformVersion切到上一个版本的映射关系基座加载子应用时就会指向历史构建产物。这里的关键是子应用的构建产物不能覆盖式部署要在对象存储里保留历史版本NGINX上按版本号作为目录区分。5.2 灰度发布落地先内部子应用后全量微前端架构的灰度发布比单体要灵活。我们在基座层做了一个开关系统通过query参数或者cookie把用户流量分桶。比如新上线的user-app 3.0版本需要灰度我们把user-app的entry地址改成灰度地址同时设置灰度规则白名单里包含某些测试账号和内部员工账号命中灰度规则的就走新版本入口其他用户继续走稳定版。灰度到全量切换的操作非常简单因为子应用entry只是改了一个URL字符串。但要注意样式文件的加载问题灰度子应用和稳定版子应用如果并存可能会出现样式文件被浏览器缓存串掉的偶发问题。解决方式是在子应用入口URL上额外挂一个版本号参数每次发布版本号变化浏览器会强制拉取新版本资源。5.3 CI/CD流水线的模板化我们为每个子应用和后端服务编写了统一的流水线模板。前端流水线的步骤是代码拉取、依赖安装、单元测试、项目构建、产物上传对象存储、更新版本映射表。后面两步是关键产物一旦上传就不可变发布时只需要改映射表。后端流水线增加了镜像构建和Kubernetes滚动更新步骤跟单体时的发布流程基本一致。这套流水线跑起来之后最直观的变化是发版时间从半天变成半小时以内。业务方提一个小需求当天就能走完测试和发布这在以前是不可想象的。而且由于每个子应用独立发版某个模块临时上线修改也不会影响其他模块产品经理再也不用等一个统一发版窗口了。6. 沙箱隔离与样式冲突微前端架构最容易翻车的地方微前端平台上线三个月之后我们统计了工单系统里反馈的前端问题发现样式错乱和JS冲突占了将近六成。这两个问题几乎是每个微前端团队都会遭遇的坎处理不好整个平台都会被钉在耻辱柱上。我单独开一节把我们的排查思路和最终方案讲清楚。6.1 默认沙箱的边界与局限qiankun的沙箱分为JS沙箱和样式沙箱。JS沙箱默认开启原理是用ES6的Proxy对window对象做代理子应用运行时的所有全局变量写入和读取都被拦截应用卸载时再把变更的属性清除干净。这套机制在处理Vue这类框架时效果很好因为Vue本身不太依赖真正的全局污染。但有一种情况JS沙箱会失效子应用通过document写入了一个script标签指向外部脚本这个外部脚本直接操作window不受Proxy约束。我们的营销子应用当时引入了一款第三方数据可视化SDK就是这种玩法上线后频繁出现页面卡死。最后我们把SDK的加载方式从动态注入改成了npm包引用问题才解决。如果你也遇到类似情况优先排查动态script标签。样式隔离我实际跑下来experimentalStyleIsolation的效果是给子应用的样式选择器加上scope属性把它限制在子应用挂载的DOM容器里。但这种隔离不彻底因为子应用的全局样式里如果定义了body或html标签的样式仍然会穿透到主应用。我们最终的兜底方案是强制约定子应用不得使用元素选择器写全局样式所有组件样式必须带scoped。这一个约定解决了我90%的样式冲突。6.2 弹窗和浮层挂载位置的坑样式隔离还带出一个隐藏问题子应用里的弹窗组件默认是挂载到body下面的因为Element UI这些组件库的弹层是append到body的。一旦挂到body就脱离了子应用的样式作用域隔离效果直接失效而且弹窗的z-index还会跟主应用的全局弹窗打架。处理方案有两种。第一种是给组件库的弹层挂载点重新指定Element UI的Dialog可以传append-to-bodyfalse并把弹层绑定到子应用根节点上。第二种是主应用和子应用统一约定z-index的管理规则主应用的全局弹窗限制在z-index 1000以内子应用弹窗往上叠加以100递增。我们采用了第一种为主第二种为辅目前弹窗类问题基本绝迹。6.3 公共组件与依赖的共享策略再梳理样式问题解决之后我们把公共依赖的共享方式重新捋了一遍。基于qiankun自带的externa机制我们指定了Vue、Vue Router、Element UI这三个包在子应用构建时不打包运行时统一从主应用引入。这个做法看似简单实际带来三个好处子应用构建体积直接砍掉一半构建速度从五分钟降到一分半内存里只有一份Vue实例沙箱隔离的压力变小子应用之间跳转时公共库的初始化时间不再重复计算。这里有个前提要注意externa共享依赖要求主应用和子应用的公共库版本必须完全一致。我们为此加了一条CI检查子应用构建时对比主应用package.json里的依赖版本不一致直接构建失败。版本冲突是微前端共享公共库时最容易爆雷的地方把这个检查做成自动化之后省了大量排障时间。7. 系统验证与效果拆分前后的性能、效率、稳定性对比讲完实现最后放一组我们平台上线跑通后的真实数据。拆分的价值最终要体现在这些可量化的指标上。构建效率上单模块平均构建时间从20分钟下降到1到3分钟因为每个子应用只构建自己的代码。全量发版部署时间从一个下午压缩到40分钟以内紧急修复一个子应用bug可以在15分钟内完成从提交到生效的全过程。运行时性能上首屏加载时间平均优化了约40%。原因很简单单体应用初始化时要加载全量路由和全量组件代码首屏一次加载三四兆JS微前端架构下初始加载只有主应用的壳子和当前命中的子应用资源后续切到其他子应用时再动态加载。当然子应用切换时会有一段额外的下载和初始化开销实测每个子应用首次加载约1到3秒后续切换由于浏览器缓存存在基本在500毫秒以内。稳定性上前后对比更加明显。单体架构时每个月平均一两次重大线上事故往往因为一个报表查询拖垮了核心交易链路。拆分之后服务之间物理隔离报表服务的CPU被打满订单服务完全不受影响。过去三个月核心链路零P0级故障。团队协作效率的变化虽然没有硬数据但感受很直接。以前一个前端仓库五十多个分支来回合光解决冲突就要半天。现在六个子应用六个仓库每个团队在自己的一亩三分地里干活合并冲突几乎没有了。新同事入职后的上手速度也快得多只需要关心自己负责的那个子应用和后端服务。8. 复盘与建议这套方案适合谁、哪里还可以做得更好做了这么多改造如果让我一句线概括微前端架构这件事那就是它不是银弹但当你真的遭遇了多业务线并行开发和频繁发版的痛它可能是目前工程实践里最成熟的那条路。最后给正在评估这套方案的团队三条建议。第一拆分前一定要想清楚自己的动机。如果你的系统只有两条业务线团队只有几个人一个月才发一次版那完全没有必要上微前端。维护基座、处理沙箱问题、管理版本映射这些复杂度是实打实的新增成本。微前端解决的主要是组织级协作效率问题不是代码级的技术炫技问题。第二拆分的过程要分步走先做前端还是先做后端要根据团队情况来。我们是前端先行因为前端的构建和发布瓶颈最痛。但如果你后端接口混乱、数据库耦合严重那建议后端先做模块化的逻辑梳理否则前端拆完发现后端还是一坨联调时会非常痛苦。第三尽量在一开始就把自动化质量门禁做好。我们的单元测试覆盖率、代码扫描规则、静态依赖检查都是在拆分后一个月内补齐的。这些门禁保证了六个子应用在高速迭代的时候不会往整体架构里注水。如果等代码已经写乱了再补基建付出的代价要翻几倍。另外有件事我一直想做但还没做完多环境联调的统一入口。目前每个子应用在本地开发时要各自启动一个devServer新同学上手要开六个终端窗口非常劝退。后续的计划是做一个CLI工具读取版本映射表一键拉起所有依赖服务的本地环境让整个平台的开发体验回到单体时代那么清爽。这个工具做完再来分享。
返回列表