前端微服务设计

673 阅读6分钟

前言

前端微服务能解决什么痛点,为什么需要前端微服务?

1.后台系统繁多。一个域名对应一个前端服务,或者对应一个前端系统。

2.系统变大后较难维护。随着需求增加,需管理的代码量增加,发展到后期较难维护。

3.上线风险。系统全量代码上线,风险较大。

4.统一技术栈的局限性。

方案思路设计

初步设计

目前的主要实现方式: 1.iframe嵌套方式; 2.资源点加载方式; 3.路由注册方式;

实现前端微服务,我们需要做到什么?

1.将前端代码拆分,随着业务需求增加,将代码拆分成多个项目;

2.实现一个前端服务作为上层入口。即一个域名,可包括所有系统;

3.统一权限的控制,根据权限区分当前角色可查看那些系统页面;

4.统一的登陆权限,SSO。

5.路由控制方案,根据路由分发到不同的前端项目;

请求分发

可以根据ng的配置,来实现请求的分发。

ng配置示例:

转发规则上限制数据请求格式必须是系统名+Api做前缀这样保障了各个系统之间的请求可以完全隔离。

server {
    listen          80;
    server_name     xxx.xx.com;

    location  /project/api/ {
        set $upstream_name "server.project";
        proxy_pass  http://$upstream_name;
    }
    ...

    location  / {
        set $upstream_name "web.portal";
        proxy_pass  http://$upstream_name;
    }
}

资源点加载方式:

这种方式的设计核心于路由注册方式大致相同

  • 主框架的定位则仅仅是:导航路由 + 资源加载框架。

路由系统及 Future State

Future State 如果在一个前端微服务框架中,进行如下操作:

此时浏览器的地址可能是 bigdata.qingsongchou.com/subApp/123/… 想象一下,此时我们手动刷新一下浏览器,会发生什么情况?

由于我们的子应用都是 lazy load 的,当浏览器重新刷新时:

  • 主框架资源会被重新加载
  • 同时异步load子应用的静态资源
  • 主应用的路由系统已经激活
  • 子应用的资源可能还没有完全加载完毕

从而导致路由注册表里发现没有能匹配子应用 /subApp/123/detail 的规则,这时候就会导致跳 NotFound 页或者直接路由报错。

这个问题在所有 lazy load 方式加载子应用的方案中都会碰到,早些年前 angularjs 社区把这个问题统一称之为 Future State。

为什么要说Future State

这是在前端微服务的方案设计中不得不关注的一个问题。为了解决这中问题,我们需要设计一套路由加载方案:

  • 主框架配置子应用的路由为subApp: { url: '/subApp/**', entry: './subApp.js' }
  • 当浏览器的地址为 /subApp/abc 时,先加载 entry 资源,待 entry 资源加载完毕,确保子应用的路由系统注册进主框架之后,再去由子应用的路由系统接管 url change 事件。
  • 同时在子应用路由切出时,主框架需要触发相应的 destroy 事件,子应用在监听到该事件时,调用自己的卸载方法卸载应用,如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)。

如上方案将url地址和静态资源相关联起来,建立起路由系统;

组合

因为需要做到各个子系统单独打包部署,各系统之间与主应用不存在过多的绑定联系。

我们需要使用运行时加载子应用这种方案。

js入口 or HTML入口

确定以什么形式引入子系统资源。

  • js入口的方式,是子应用将资源打成一个 entry script,但这个方案的限制也颇多:

    • 子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。
    • 资源的并行加载等特性也无法利用上
  • HTML入口方式将更为灵活,主框架可以通过 fetch html的方式获取子应用的静态资源,同时将 HTML document作为子节点塞到主框架的容器中:

    • 极大的减少主应用的接入成本
    • 子应用的开发方式及打包方式基本上也不需要调整
    • 解决子应用之间样式隔离的问题

对比如上两种方案,显然HTML入口引入的方式更为合适;

那么HTML入口方案下,主框架注册子应用的方式则变成:

framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})

通过整合页可以减少一次请求html:

framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})

路由注册方式

路由的注册是整个设计方案的核心,通过路由映射来区分不同的系统,这样才能使得“请求”得到分发。

“子项目”的路由应该由自己控制,而整个系统的导航是“主项目”提供的。

路由注册是通过子项目向主项目实施路由注册的方式:

let Router = <Router
                fetchMenu = {fetchMenuHandle}
                routes = {routes}
                history = {history}
            >
ReactDOM.render(Router,document.querySelector("#app"));

<Router>
    <Route path="/" component={App}>
        <Route path="/namespace/xx" component={About} />
        <Route path="inbox" component={Inbox}>
            <Route path="messages/:id" component={Message} />
        </Route>
    </Route>
</Router>

具体注册使用了全局的window.app.routes,“主项目”从window.app.routes获取路由,“子项目”把自己需要注册的路由添加到window.app.routes中,子项目的注册如下:

let app = window.app = window.app || {}; 
app.routes = (app.routes || []).concat([
{
  code:'attendance-record',	
  path: '/attendance-record',
  component: wrapper(() => async(require('./nodes/attendance-record'), 'caiwu')),
}]);

在主系统中访问页面的时候,子系统自行注册了路由。

js作用域控制

  • 子项目

路由注册成功后,问题就来到了资源加载上,路由对应着资源(js,css,img...),加载对应的资源后,作用域问题就需要得到解决。

同样通过window.app来控制项目作用域;

let app = window.app || {};
app = {
    routes:[...],
    init:function(namespace,reducers){...}       
};

window.app主要功能: routes 用于存放全局的路由,子项目路由添加到window.app.routes,用于完成路由的注册 init 注册入口,为子项目添加上namesapce标识,注册上子项目管理数据流的reducers

子项目完整的注册:

import reducers from './redux/caiwu-reducer';
let app = window.app = window.app || {}; 
app.routes = (app.routes || []).concat([
{
  code:'attendance-record',	
  path: '/attendance-record',
  component: wrapper(() => async(require('./nodes/attendance-record'), 'caiwu')),
  // ... 其他路由
}]);
 
function wrapper(loadComponent) {
  let React = null;
  let Component = null;
  let Wrapped = props => (
    <div className="namespace-caiwu">
      <Component {...props} />
    </div>
  );
  return async () => {
    await window.app.init('namespace-caiwu',reducers);
    React = require('react');
    Component = await loadComponent();
    return Wrapped;
  };
}

其中做了这几件事情:

  1. 把路由添加到window.app中。
  2. 业务第一次功能被调用的时候执行window.app.init(namespace,reducers),注册项目作用域和数据流的reducers。
  3. 对业务功能的挂载节点包装一个根节点:Component挂载在className为namespace-kaoqin的div下面。

这样就完成了“子项目”的注册,“子项目”的对外输出是一个入口文件和一系列的资源文件,这些文件由webpack构建生成。

  • 主项目

主项目的init方法:

let inited = false;
let ModalContainer = null;
app.init = async function (namespace,reducers) {
  if (!inited) {
    inited = true;
    // 加载common文件。
    ModalContainer = document.createElement('div');
    document.body.appendChild(ModalContainer);
  }
  ModalContainer.setAttribute('class', `${namespace}`);
  mountReducers(namepace,reducers)
};

创建一个div容器。

并挂reducer在redux上。

css作用域控制

//webpack打包部分,在postcss插件中 添加namespace的控制
config.postcss.push(postcss.plugin('namespace', () => css =>
    css.walkRules(rule => {
        if (rule.parent && rule.parent.type === 'atrule' && rule.parent.name !== 'media') return;
        rule.selectors = rule.selectors.map(s => `.namespace-kaoqin ${s === 'body' ? '' : s}`);
    })
));

使用webpack插件,给css样式表加上对应的命名空间;

至此,我们完成了系统的拆分和整合。下面总结一下:

1.做到前端微服务的方式很多,核心载于建立主应用和子应用之间的联系;

2.加载资源的方式,亦是核心之一。

3.因为是多个子应用在同一个主应用中运行,所以作用域隔离和央视隔离是需要解决的问题。