一、网站架构的演进
整个演进过程可以分为单体架构,垂直架构,SOA(Service-Oriented Architecture)架构(面向服务/分布式),以及微服务(MSA)。
1.单体架构
单体架构是典型的巨石型应用,所有的功能都在一个项目工程里边,好处是便于部署,只需要打一个包就行。此时互联网应用与企业级应用并没有什么区别,架构上都是标准三层:数据访问层、业务逻辑层和视图层。缺点也很明显,项目业务逻辑层和视图层紧紧地耦合在一起,而业务一旦某个服务不可用,有可能会导致整个系统不可用,系统性能只能通过集群节点,提升也很有限。
在java中spring mvc架构也属于单体架构,这种架构将业务逻辑主要集中在controller层当中。在ajax之前,前端展示视图,而用户的操作是需要通过控制层提交到后端,由后端的数据模型更新视图,再将视图返回到前端进行展示。
2.垂直架构
而垂直架构,核心是划分应用,将一个大型应用拆分成多个子应用,互不干扰,耦合度进一步降低。但此时不管项目有多少个子应用,都还是在一个项目工程里面。
垂直架构的粗粒度,意味着哪怕是一个子应用,也有可能是一个高度耦合的应用。
3.SOA架构(面向服务/分布式)
随着系统规模增大,将业务集中在一起的架构已经不能满足需求,此时需要将应用进行进一步拆分成粒度更细的服务模块,模块可独立部署,强调重用,将服务部署到不同的机器时,服务之间通过接口进行通讯,就像在调用本地服务一样。
一个分布式系统具备以下特征:
- 分布性:计算机在空间上随意分布。
- 对等性:计算机之间没有主从之分。
- 并发性:计算机对同一资源的并法操作。
业务进一步复杂,服务之间的相互调用很容易变成一团乱麻,剪不断理还乱。于是在SOA架构中引入了ESB(Enterprise Service Bus)模型。各个服务不直接通信,而是接入ESB这个中间件当中,调用服务的时候都要经过ESB进行转发。
举个例子,在上面垂直架构的示意图,用户中心、后台管理系统以及认证中心都要实现自己的登录注册逻辑,在SOA架构当中,就可以把用户的登录注册抽离成一个服务,管理后台或用户中心可以调用这个登录服务,达到了服务重用的目的。
这里既然提到了服务间的通信,顺便聊一聊分布式架构中的网络通信。
对于前端来说,常见网络通信的可能就是HTTP请求了,在HTTP1.0请求报文中,只有请求体部分是使用了二进制编码,请求头还是使用文本编码,非常占字节数,而SOA中服务调用对性能是有极高要求的,于是很多SOA架构都自己实现基于TCP的RPC(Remote Procedure Call Protocol)远程调用。
这并不意味着RPC与HTTP是并行的,RPC更多是一种思想,RPC通常包含传输协议和序列化协议。
比如说阿里的dubbo分布式框架,它的RPC支持有自己实现的基于TCP的dubbo协议,也有http、webservice等协议。同样还有Google的GRPC,GPRC的传输实现是基于http2.0的协议,http2.0对请求体解析也使用了二进制格式,同时对请求头使用encoder进行压缩,而序列化协议使用了ProtoBuf。
而前端请求后端接口,同样也是一种RPC,使用HTTP协议进行数据传输,使用json进行数据序列化。
分布式架构在解决单体应用、垂直架构所带来的高耦合,所有应用杂糅在一个项目当中的问题时,也带来新的问题,高可用高性能高并发,集群的容灾处理,数据的一致性等。
对于高可用,b站技术总监谈过几个方面:1负载均衡2限流3重试4超时5应对连锁故障,虽然前段时间b站出现了宕机事故,但这并不影响。对于高并发的处理,笔者的了解也只到mysql的主从复制读写分离,分表分库,采用redis缓存等,而高性能,问就是只知道连接调优数据库调优sql语句调优使用缓存这些字,笔者也没有更多了解。
4.微服务
微服务可以说是是SOA的进一步细化,将功能拆分到各个离散的服务当中,基于业务的、不可继续拆分的、最小单一职能单位,更加强调独立性。在分布式架构当中,由于ESB的存在,导致架构通常与某个技术栈是强绑定的关系;微服务则是基于服务发现的API网关,去掉了ESB总线,这也让微服务可以选择不同的语言。
微服务的优点主要是:
- 易于扩展,当某个服务的性能达到瓶颈时,只需要扩展该节点即可,不需要像单体应用那样粗粒度地提升整体性能。
- 部署简单,只需要将发生修改的服务部署即可,单体应用则要整个系统重新部署。
- 技术异构,只要服务提供通用性接口即可,同时RPC通信方式也不能与语言强耦合。
- 隔离性,单个微服务挂掉不会导致系统宕机。
由于微服务的技术异构性,这些服务直接部署在计算机上,显然不现实,光是环境混乱就让人头大。那么,一个服务就是一个虚拟机显然也不合适,且不说性能,就是如何管理这些微服务,都是大问题。那么轻量、封装、易迁移、易交付的容器技术就是不二选择,前文也讲到docker,创建出了一个隔离的软件执行环境,特别适合微服务。
而单体应用拆分成微服务之后,首当其冲的就是部署问题。数量庞大的微服务,想要一次启动,少不了编排动作。而K8S完美解决了调度,负载均衡,集群管理等微服务面临的问题。
二、 BFF(backend for frontend)
随着互联网多终端多平台多业务形态的发展,前后端的数据交互越发复杂,在理想情况下,这种复杂性是由后端承担,前端只从接口中拿到自己想要的数据。
但这只是理想化的,不管是因为领域模型还是微服务架构,通常一个前端的页面通常需要请求好几个接口,自行组合后才能完成数据的展示。而有时候终端或业务细节的差异性,后端则需要实现更多的接口。更遑论接口在迭代的过程中,容易变得复杂起来。
而BFF就是解决这个问题的,作为中间件,前端不需要感知后端接口的修改,只需要在BFF中进行变动即可,BFF对前端屏蔽了服务链路,让前端只关注自己需要的数据。
传统上后端提供的接口RESTful的,后端定义好接口后,前端通过get、post、delete、put来直接操作服务端资源。
客户端请求示例
GET http://example.com/user?id=1 HTTP/1.1 //获取用户信息/
服务端返回示例
{
"name":"张三",
"age":18,
"lv":"1",
"ponit":"122"
}
而facebook于2015年推出的GraphQL,作为一种替代RESTful模式的api查询语句,所有的请求通过一个入口进入服务器,在服务端定义好Schema作为请求的入口,前端的请求通过GraphQL得到具体的Schema,按需获取自己想要的数据,同时能将数据进行分层。
客户端请求示例
{
operationName:"GetUserInfo",
query:"query GetUserInfo {
username,
age
}"
}
服务端返回示例
{
"name":"张三",
"age":18
}
使用GraphQL后的BFF架构:
三、微前端
前端技术的发展,撇开萌芽阶段不去说,大体上可以分成三个阶段:以MVC架构为代表的前后端未分离时代,AJAX出现以及JQuery统治的web 2.0,最后是目前MVVM模式和前端工程化相结合的的工业时代。
1.现状
现在的前端基本都是基于vue、react和angular为首的MVVM框架进行开发,极大地提高了前端开发的效率。在上文介绍网站架构演进的时候提到了MVC架构,但那是后端代码和前端代码耦合在一起,而前端的MVC是在web2.0的时候,前端拥有了通过ajax,那么响应用户操作,只需要向后端请求数据即可,然后再通过控制层来操作dom视图(view)。
鉴于笔者对angular的了解为零,这里就只谈谈vue和react的MVVM思想。对于vue来说,利用发布订阅模式,实现了数据和视图之间的双向绑定,在这种模式之下,对dom的更新操作交由框架处理。至于react,16.8之前的纯粹的react只能算是一个MV,获取model后展示view,只有配合如redux进行状态管理才能算是MVVM的模式。
2.困境
这种MVVM模式实际上解决的是软件层面的架构问题,而非系统层面的解决方案。在目前的大部分前端应用来说,无论是react还是vue,就是一个单体应用,使用webpack将所有的模块打包后部署,这与java中将应用打包成war后发布其实是一样的。在这种单体应用的架构之下,对项目的改动哪怕只是一个极其微小的模块,也要将所有模块重新打包,发布整个应用。
当应用的业务够多够复杂,所带来的,不仅仅是发布部署耗时,还有对现在敏捷开发快速迭代也是极大的挑战,应用愈发臃肿,等到难为维护的时候,只有重构这条路。
同时,package依赖是轻易不能动的,旧业务和新业务引用同个依赖,新业务想使用更新版本的特性或api,可能会带来无法预知的后果。
再者,对技术栈也有一致性的要求,在vue里边是没法写react的,虽然可以通过ast插件将vue组件解析转换到react中使用也不是不行但是应该很少有人会这么做吧?
3.框架
于是,很多团队开始探索微服务这种思想在前端的实现,将一个庞大的前端项目进行分解,每一个细粒度的模块都可以独立开发测试部署,模块无需感知基座应用的存在,只需要关注自身的业务。这里介绍下微前端的几种实现方式:
-
iframe
iframe是html的一个重要标签,可以在网页文档中再嵌套一个子文档,同时实现了与主文档的环境隔离,是一个完美的沙箱。通过这种方式,可以实现微前端的思想,只需要一个主文档,然后各个子应用直接嵌套进去即可。
但iframe是有其局限性的,在这种模式下路由地址管理会变得困难,弹窗只能在iframe中展示。每次子应用的进入都是浏览器上下文重建,资源重新加载的过程。
同时,如果对浏览器有一定了解的话,一个iframe是会单独占用一个进程的,这就意味着在微服务的架构之下,倘若一个页面中加载了多个iframe,进程数过多将会影响设备性能,进而导致页面卡顿等现象。
-
微件化
微型组件widget是一段可以直接运行的代码,业务团队只需开发编译好自己的业务代码,部署到服务器,基座应用应用通过
<script>
标签引用,并调用相关函数渲染组件。与iframe不同,这种方式组件无需通过postMessage这个浏览器api进行通讯,直接在调用函数时传参即可。除此之外,利用Web Components实现自定义HTML元素也是同样的原理,虽然浏览器的支持并不是很好,还是需要polyfill。
代表的框架有基于single-spa的qiankun。
使用了proxy和快照来处理隔离,qiankun会维护三个状态池,一个储存子应用新增的主应用全局变量、一个储存子应用更新的主应用全局变量、一个子应用的子应用全局变量。在不支持proxy的浏览器上,会有降级处理的方法,原理是对window和记录的快照做diff。
在这种模式下,qiankun通过子应用激活时还原子应用状态,子应用卸载时还原主应用状态,来实现JS沙箱。
-
模块联邦
这是基于webpack5和webpack5的模块联邦(module federation)的一种方案,让webpack达到了线上runtime的效果,而不仅仅是作为本地打包的一个工具。在微件化当中,子应用独立打包,模块跟解耦了,但是无法抽取公共依赖,而该方案是直接一个应用的包在另一应用中使用。
这是一种去中心化的微前端方案,这里以emp为例进行说明。
在qiankun当中,是需要一个基座应用来集成其他微应用的,而在emp中,每一个微应用都可以通过远程调用引入共享模块,通过module federation将多个独立构建的应用组合成一个应用。
4.实践
这里选择了qiankun,以vue3+vite为基础,从零开始搭建。创建vue3的项目过程这里就列一下流水账,也没必要浪费篇幅细说。
-
初始化基座应用(主应用)
贴一下
package.json
,用到的插件。// /package.json { "name": "demo", "version": "0.0.0", "scripts": { "dev": "vite", "build": "vite build" }, "dependencies": { "@vitejs/plugin-vue": "^1.6.0", "element-plus": "^1.0.2-beta.71", "qiankun": "^2.4.9", "vite": "^2.5.1", "vue": "^3.2.2", "vue-router": "^4.0.11", "vuex": "^4.0.2" }, "devDependencies": { "@types/node": "^16.6.2", "@typescript-eslint/eslint-plugin": "^4.29.1", "@typescript-eslint/parser": "^4.29.1", "@vitejs/plugin-vue": "^1.4.0", "@vue/compiler-sfc": "^3.2.2", "eslint": "^7.32.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^8.3.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-typescript": "^2.4.0", "eslint-plugin-import": "^2.24.1", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-vue": "^7.16.0", "node-sass": "^6.0.1", "prettier": "^2.3.2", "sass": "^1.38.0", "sass-loader": "^12.1.0", "typescript": "^4.3.5", "vite": "^2.5.0", "vue-tsc": "^0.2.3" } }
在用到需要具体定义的内容的时候全局处理,这里只声明一下'*.vue'就可以
// /src/shim.d.ts /* eslint-disable */ declare module '*.vue' { import type { DefineComponent } from 'vue' const component: DefineComponent<{}, {}, any> export default component; }
配置一下
vite.config.js
// /vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' export default defineConfig({ plugins: [vue()], base: './', server: { host: 'localhost', port: 3000 }, resolve: { alias: { '@': path.resolve(__dirname, './src') } } })
在
App.vue
中准备子应用容器// /src/App.vue <template> <Menu></Menu> <div v-for="item of menuList" :key="item.path" :id="item.name"></div> </template> <script lang="ts"> import { defineComponent } from 'vue' import { useStore } from 'vuex' import Menu from '@/layout/Menu.vue' export default defineComponent({ name: 'App', components: { Menu }, setup() { const store = useStore() const { menuList } = store.getters return { store, menuList } } }) </script>
准备
store
,用以存储一些临时状态数据,将子应用apps
存到vuex
当中,便于管理。state: { return { menuList: [ { name: 'vue2', entry: `//localhost:4396`, //自行替换地址 container: '#vue2', activeRule: (name:string)=> }, { name: 'react17', entry: `//localhost:2200`, container: '#react17', activeRule: '#/react17' } ] } }, getters: { menuList(state: State) { return state.menuList } }
将
qiankun
的初始化抽离出来:- 方便后续在
beforeLoad
中做权限校验。 - 修改
activeRule
即可支持一个页面中展示多个微应用。
// /src/qiankun.ts import { registerMicroApps, start } from 'qiankun' import store from '@/store/index' const apps = store.getters.menuList.map((v: any) => { return { ...v, activeRule: (location: Location) => { return location.hash.startsWith(v.activeRule) } } }) function init() { registerMicroApps(apps, { beforeLoad: () => { return Promise.resolve() } }) start({ sandbox: { strictStyleIsolation: true } }) } export default init
- 方便后续在
-
vue2.x的子应用
用vue cli生成一个子应用vue2,在
main.js
中按qiankun
的要求导出三个函数,分别是bootstarp
,mounted
,unmounted
。- 利用单例模式,保证只有一个实例化子应用。
- 将vue.config.js中的publicPath留空,在此处动态配置。
- 微应用加载后容器 DOM 节点不存在了,需要修改
$mount
时的节点查找范围。
// /src/main.js function render(props) { // props 组件通信 const { container } = props instance = new Vue({ render: h => h(App) }).$mount(container ? container.querySelector('#app') : '#app') } if (!window.__POWERED_BY_QIANKUN__) { // 如果是独立运行,则手动调用渲染 render('app'); } if(window.__POWERED_BY_QIANKUN__){ // 如果是qiankun使用到了,则会动态注入路径 // eslint-disable-next-line no-undef __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } /* 根据 qiankun 的协议需要导出 bootstrap/mount/unmount */ export async function bootstrap() { console.log("VueMicroApp bootstraped"); } export async function mount(props) { render(props); } export async function unmount() { // 卸载时销毁应用 instance.$destroy(); }
修改
vue.config.js
的导出这三个生命周期钩子。// /vue.config.js module.exports = { ... output: { library: `vue2demo`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_vue2demo`, }, ... }
-
react17的子应用
使用webapck5进行搭建,一路流水账。大致上与vue2.x的子应用相同。
这里是入口文件的代码。
// /src/index.js import React from 'react'; import ReactDOM from 'react-dom'; if (window.__POWERED_BY_QIANKUN__) { __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; } const App = () => { return <div>react17</div>; }; const render = (props)=>{ const { container } = props; ReactDOM.render(<App />, container ? container.querySelector('#root') : document.querySelector('#root')); } if (!window.__POWERED_BY_QIANKUN__) { render({}); } export async function bootstrap() { } export async function mount(props) { render(props); } export async function unmount(props) { const { container } = props; ReactDOM.unmountComponentAtNode(container ? container.querySelector('#root') : document.querySelector('#root')); }
webpack的配置
const path = require("path"); const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = (env) => { return { mode: "development", entry: { index: './src/index.js' }, output: { // 打包文件根目录 path: path.resolve(__dirname, "dist/"), // 微应用的包名,这里与主应用中注册的微应用名称一致 library: "react17", // 将你的 library 暴露为所有的模块定义下都可运行的方式 libraryTarget: "umd", }, plugins: [ // 生成 index.html new HtmlWebpackPlugin({ filename: "index.html", template: "./build/index.html", }), ], module: { rules: [ { test: /\.(jsx|js)?$/, use: ["babel-loader"], include: path.resolve(__dirname, 'src'), }, ] }, devServer: { port: 2200, host: '0.0.0.0', allowedHosts:"all", headers: { "Access-Control-Allow-Origin": "*", }, }, } }
至此,前端微应用的基础架子就搭建得差不多了。详细文档可以去qiankun官网进行查看。
5.结语
本文站在宏观的视角,谈到的只是在软件层面的架构,重点都在于粒度,而这个粒度也不是越细越好,而是要根据实际情况处理,比如一个简单的页面,用jq会更合适,所有内容丢到一个页面里面去,也不会很大,这方面更多的是体现广度。如果往细了谈,vue有自己一套架构知识,比如源码、性能优化、自动化测试、vuex/vue-router、ssr(nuxt)等,表现的则是深度。
架构之路也刚刚开始,一个架构师的边界,并不局限于代码,还有CI/CD,平台建设、CDN负载均衡、监控报警系统等等。