前端工程师所要知道的架构知识:从网站架构的演进到微前端

443 阅读12分钟

一、网站架构的演进

整个演进过程可以分为单体架构,垂直架构,SOA(Service-Oriented Architecture)架构(面向服务/分布式),以及微服务(MSA)。

1.单体架构

单体架构是典型的巨石型应用,所有的功能都在一个项目工程里边,好处是便于部署,只需要打一个包就行。此时互联网应用与企业级应用并没有什么区别,架构上都是标准三层:数据访问层、业务逻辑层和视图层。缺点也很明显,项目业务逻辑层和视图层紧紧地耦合在一起,而业务一旦某个服务不可用,有可能会导致整个系统不可用,系统性能只能通过集群节点,提升也很有限。

image.png

在java中spring mvc架构也属于单体架构,这种架构将业务逻辑主要集中在controller层当中。在ajax之前,前端展示视图,而用户的操作是需要通过控制层提交到后端,由后端的数据模型更新视图,再将视图返回到前端进行展示。

image.png

2.垂直架构

而垂直架构,核心是划分应用,将一个大型应用拆分成多个子应用,互不干扰,耦合度进一步降低。但此时不管项目有多少个子应用,都还是在一个项目工程里面。

image.png

垂直架构的粗粒度,意味着哪怕是一个子应用,也有可能是一个高度耦合的应用。

3.SOA架构(面向服务/分布式)

随着系统规模增大,将业务集中在一起的架构已经不能满足需求,此时需要将应用进行进一步拆分成粒度更细的服务模块,模块可独立部署,强调重用,将服务部署到不同的机器时,服务之间通过接口进行通讯,就像在调用本地服务一样。

一个分布式系统具备以下特征:

  1. 分布性:计算机在空间上随意分布。
  2. 对等性:计算机之间没有主从之分。
  3. 并发性:计算机对同一资源的并法操作。

业务进一步复杂,服务之间的相互调用很容易变成一团乱麻,剪不断理还乱。于是在SOA架构中引入了ESB(Enterprise Service Bus)模型。各个服务不直接通信,而是接入ESB这个中间件当中,调用服务的时候都要经过ESB进行转发。

image.png

举个例子,在上面垂直架构的示意图,用户中心、后台管理系统以及认证中心都要实现自己的登录注册逻辑,在SOA架构当中,就可以把用户的登录注册抽离成一个服务,管理后台或用户中心可以调用这个登录服务,达到了服务重用的目的。

这里既然提到了服务间的通信,顺便聊一聊分布式架构中的网络通信。

对于前端来说,常见网络通信的可能就是HTTP请求了,在HTTP1.0请求报文中,只有请求体部分是使用了二进制编码,请求头还是使用文本编码,非常占字节数,而SOA中服务调用对性能是有极高要求的,于是很多SOA架构都自己实现基于TCP的RPC(Remote Procedure Call Protocol)远程调用。

这并不意味着RPC与HTTP是并行的,RPC更多是一种思想,RPC通常包含传输协议和序列化协议。

image.png

比如说阿里的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总线,这也让微服务可以选择不同的语言。

微服务的优点主要是:

  1. 易于扩展,当某个服务的性能达到瓶颈时,只需要扩展该节点即可,不需要像单体应用那样粗粒度地提升整体性能。
  2. 部署简单,只需要将发生修改的服务部署即可,单体应用则要整个系统重新部署。
  3. 技术异构,只要服务提供通用性接口即可,同时RPC通信方式也不能与语言强耦合。
  4. 隔离性,单个微服务挂掉不会导致系统宕机。

image.png

由于微服务的技术异构性,这些服务直接部署在计算机上,显然不现实,光是环境混乱就让人头大。那么,一个服务就是一个虚拟机显然也不合适,且不说性能,就是如何管理这些微服务,都是大问题。那么轻量、封装、易迁移、易交付的容器技术就是不二选择,前文也讲到docker,创建出了一个隔离的软件执行环境,特别适合微服务。

而单体应用拆分成微服务之后,首当其冲的就是部署问题。数量庞大的微服务,想要一次启动,少不了编排动作。而K8S完美解决了调度,负载均衡,集群管理等微服务面临的问题。

二、 BFF(backend for frontend)

随着互联网多终端多平台多业务形态的发展,前后端的数据交互越发复杂,在理想情况下,这种复杂性是由后端承担,前端只从接口中拿到自己想要的数据。

但这只是理想化的,不管是因为领域模型还是微服务架构,通常一个前端的页面通常需要请求好几个接口,自行组合后才能完成数据的展示。而有时候终端或业务细节的差异性,后端则需要实现更多的接口。更遑论接口在迭代的过程中,容易变得复杂起来。

image.png

而BFF就是解决这个问题的,作为中间件,前端不需要感知后端接口的修改,只需要在BFF中进行变动即可,BFF对前端屏蔽了服务链路,让前端只关注自己需要的数据。

image.png

传统上后端提供的接口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架构:

image.png

三、微前端

前端技术的发展,撇开萌芽阶段不去说,大体上可以分成三个阶段:以MVC架构为代表的前后端未分离时代,AJAX出现以及JQuery统治的web 2.0,最后是目前MVVM模式和前端工程化相结合的的工业时代。

1.现状

现在的前端基本都是基于vue、react和angular为首的MVVM框架进行开发,极大地提高了前端开发的效率。在上文介绍网站架构演进的时候提到了MVC架构,但那是后端代码和前端代码耦合在一起,而前端的MVC是在web2.0的时候,前端拥有了通过ajax,那么响应用户操作,只需要向后端请求数据即可,然后再通过控制层来操作dom视图(view)。

image.png

鉴于笔者对angular的了解为零,这里就只谈谈vue和react的MVVM思想。对于vue来说,利用发布订阅模式,实现了数据和视图之间的双向绑定,在这种模式之下,对dom的更新操作交由框架处理。至于react,16.8之前的纯粹的react只能算是一个MV,获取model后展示view,只有配合如redux进行状态管理才能算是MVVM的模式。

image.png

2.困境

这种MVVM模式实际上解决的是软件层面的架构问题,而非系统层面的解决方案。在目前的大部分前端应用来说,无论是react还是vue,就是一个单体应用,使用webpack将所有的模块打包后部署,这与java中将应用打包成war后发布其实是一样的。在这种单体应用的架构之下,对项目的改动哪怕只是一个极其微小的模块,也要将所有模块重新打包,发布整个应用。

当应用的业务够多够复杂,所带来的,不仅仅是发布部署耗时,还有对现在敏捷开发快速迭代也是极大的挑战,应用愈发臃肿,等到难为维护的时候,只有重构这条路。

同时,package依赖是轻易不能动的,旧业务和新业务引用同个依赖,新业务想使用更新版本的特性或api,可能会带来无法预知的后果。

再者,对技术栈也有一致性的要求,在vue里边是没法写react的,虽然可以通过ast插件将vue组件解析转换到react中使用也不是不行但是应该很少有人会这么做吧?

3.框架

于是,很多团队开始探索微服务这种思想在前端的实现,将一个庞大的前端项目进行分解,每一个细粒度的模块都可以独立开发测试部署,模块无需感知基座应用的存在,只需要关注自身的业务。这里介绍下微前端的几种实现方式:

  1. iframe

    iframe是html的一个重要标签,可以在网页文档中再嵌套一个子文档,同时实现了与主文档的环境隔离,是一个完美的沙箱。通过这种方式,可以实现微前端的思想,只需要一个主文档,然后各个子应用直接嵌套进去即可。

    但iframe是有其局限性的,在这种模式下路由地址管理会变得困难,弹窗只能在iframe中展示。每次子应用的进入都是浏览器上下文重建,资源重新加载的过程。

    同时,如果对浏览器有一定了解的话,一个iframe是会单独占用一个进程的,这就意味着在微服务的架构之下,倘若一个页面中加载了多个iframe,进程数过多将会影响设备性能,进而导致页面卡顿等现象。

  2. 微件化

    微型组件widget是一段可以直接运行的代码,业务团队只需开发编译好自己的业务代码,部署到服务器,基座应用应用通过<script>标签引用,并调用相关函数渲染组件。与iframe不同,这种方式组件无需通过postMessage这个浏览器api进行通讯,直接在调用函数时传参即可。

    除此之外,利用Web Components实现自定义HTML元素也是同样的原理,虽然浏览器的支持并不是很好,还是需要polyfill。

    image.png

    代表的框架有基于single-spa的qiankun。

    使用了proxy和快照来处理隔离,qiankun会维护三个状态池,一个储存子应用新增的主应用全局变量、一个储存子应用更新的主应用全局变量、一个子应用的子应用全局变量。在不支持proxy的浏览器上,会有降级处理的方法,原理是对window和记录的快照做diff。

    在这种模式下,qiankun通过子应用激活时还原子应用状态,子应用卸载时还原主应用状态,来实现JS沙箱。

  3. 模块联邦

    这是基于webpack5和webpack5的模块联邦(module federation)的一种方案,让webpack达到了线上runtime的效果,而不仅仅是作为本地打包的一个工具。在微件化当中,子应用独立打包,模块跟解耦了,但是无法抽取公共依赖,而该方案是直接一个应用的包在另一应用中使用。 image.png

    这是一种去中心化的微前端方案,这里以emp为例进行说明。

    在qiankun当中,是需要一个基座应用来集成其他微应用的,而在emp中,每一个微应用都可以通过远程调用引入共享模块,通过module federation将多个独立构建的应用组合成一个应用。

4.实践

这里选择了qiankun,以vue3+vite为基础,从零开始搭建。创建vue3的项目过程这里就列一下流水账,也没必要浪费篇幅细说。

  1. 初始化基座应用(主应用)

    贴一下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
    
  2. 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`, 
       },
       ...
    }
    
  3. 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负载均衡、监控报警系统等等。