「微前端」- 将微服务理念扩展到前端开发(实战篇)

前言与大纲

本文分为理论和实战上下两篇。本篇为微前端的实战篇,共计约 5k 字,预计阅读时间 10 mins。

技术雷达之「微前端」- 将微服务理念扩展到前端开发(上:理论篇)中,我们介绍了微前端在单体应用和微服务的架构演进中所产生的缘由,将微服务理念运用到前端开发就是为了解决臃肿前端的当前现状。与此同时,合理拆分微前端也给我们的应用开发带来显而易见的好处,在本篇当中我们将逐一介绍微前端的实践方案与可能遇到的问题和对应的优化建议。

文章大纲

  • 微前端的可选实践方案(4 种 +)
    • 创建更小的 Apps(而不是 Components)
    • 如何组合微前端的 App 模块?
      • Option 1: 使用后端模板引擎插入 HTML
        • Option 1.1: 渐进式从后端进行加载
      • Option 2: 使用 IFrame 隔离运行时
      • Option 3: 客户端 JavaScript 异步加载
      • Option 4: WebComponents 整合所有功能模块
    • 不同 App 模块之间如何交互?
    • More Options…
  • 微前端的页面优化与实例
    • 多模块页面加载问题与优化建议
    • 微前端在 AEM(CMS)项目的应用
    • 现成解决方案:Single-SPA “meta framework”
  • 总结与思考:微前端的优缺点
    • 微前端的优点
    • 微前端的缺点
    • 持续思考…
  • 附:参考资料

微前端的可选实践方案(4 种+)

创建更小的 Apps(而不是 Components)

首先让我们来创建一个典型 Web 应用程序的基本组件(Header、ProductList、ShoppingCart),以 Header 组件为例:

# src/App.js
export default () =>
  <header>
    <h1>Logo</h1>
    <nav>
      <ul>
        <li>About</li>
        <li>Contact</li>
      </ul>
    </nav>
  </header>;

然后需要注意的是我们会用到 Express 对刚刚创建的 React 组件进行服务器端渲染,使之成为一个 App 模块:

# server.js
fs.readFile(htmlPath, 'utf8', (err, html) => {
  const rootElem = '<div id="root">';
  const renderedApp = renderToString(React.createElement(App, null));

  res.send(html.replace(rootElem, rootElem + renderedApp));
});

再依次创建其他 Apps 并独立部署:

如何组合微前端的 App 模块?

在每个独立团队创建好各自的 App 模块后,我们就可以将网站或 Web 应用程序视为由各种模块的功能组合。下文将介绍多种技术实践方案来重新组合这些模块(有时作为页面,有时作为组件),而前端(不管是不是 SPA)将只需要负责路由器(Router)如何选择和决定要导入哪些模块,从而为最终用户提供一致性的用户体验。

Option 1: 使用后端模板引擎插入 HTML

# server.js
Promise.all([
    getContents('https://microfrontends-header.herokuapp.com/'),
    getContents('https://microfrontends-products-list.herokuapp.com/'),
    getContents('https://microfrontends-cart.herokuapp.com/')
  ]).then(responses =>
    res.render('index', { header: responses[0], productsList: responses[1], cart: responses[2] })
  ).catch(error =>
    res.send(error.message)
  )
);
# views/index.ejs
  <head>
    <meta charset="utf-8">
    <title>Microfrontends Homepage</title>
  </head>
  <body>
    <%- header %>
    <%- productsList %>
    <%- cart %>
  </body>

但是,这种方案也存在弊端,即某些 App 模块可能会需要相对较长的加载时间,而在前端整个页面的渲染却要取决于最慢的那个模块。

比如说,可能 Header 模块的加载速度要比其他部分快得多,而 ProductList 则因为需要获取更多 API 数据而需要更多时间。通常情况下我们希望尽快将网页显示给用户,而在这种情况下后台加载时间就会变得更长。

Option 1.1: 渐进式从后端进行加载

当然,我们也可以通过修改一些后端代码来渐进式地(Progressive)往前端发送 HTML,但与此同时却徒增了后端复杂度,并且又将前端的渲染控制权交回了后端服务器。而且我们的优化也取决于每个模块加载的速度,若是进行优化就必须按一定顺序进行加载。

Option 2: 使用 IFrame 隔离运行时

<body>
  <iframe width="100%" height="200" src="https://microfrontends-header.herokuapp.com/"></iframe>
  <iframe width="100%" height="200" src="https://microfrontends-products-list.herokuapp.com/"></iframe>
  <iframe width="100%" height="200" src="https://microfrontends-cart.herokuapp.com/"></iframe>
</body>

我们也可以将每个子应用程序嵌入到各自的 <iframe> 中,这使得每个模块能够使用任何他们需要的框架,而无需与其他团队协调工具和依赖关系,依然可以借助于一些库或者 Window.postMessageAPI 来进行交互。

  • 优点
    • 最强大的是隔离了组件和应用程序部分的运行时环境,因此每个模块都可以独立开发,并且可以与其他部分的技术无关
    • 可以各自使用完全不同的前端框架,可以在 React 中开发一部分,在 Angular 中开发一部分,然后使用原生 JavaScript 开发其他部分或任何其他技术。
    • 只要每个 iframe 来自同一个来源,消息传递也就相当直接和强大。参考文档 Window.postMessageAPI
  • 缺点
    • Bundle 的大小非常明显,因为可能最终会多次发送相同的库,并且由于应用程序是分开的,所以在构建时也不能提取公共依赖关系。
    • 至于浏览器的支持,基本上不可能嵌套两层以上的 iframe(parent - > iframe - > iframe)。
  • 如果任何嵌套的框架需要能够滚动或具有 Form 表单域,那样的情况处理起来就会变得特别痛苦。Option 3: 客户端 JavaScript 异步加载
function loadPage (element) {
  [].forEach.call(element.querySelectorAll('script'), function (nonExecutableScript) {
    var script = document.createElement("script");
    script.setAttribute("src", nonExecutableScript.src);
    script.setAttribute("type", "text/javascript");
    element.appendChild(script);
  });
}

document.querySelectorAll('.load-app').forEach(loadPage);
<div class="load-app" data-url="header"></div>
<div class="load-app" data-url="products-list"></div>
<div class="load-app" data-url="cart"></div>

简单来说,这种方式就是在客户端浏览器通过 Ajax 加载应用程序,然后将不同模块的内容插入到对应的 div 中,而且还必须手动克隆每个 script 的标记才能使其工作。

需要注意的是,为了避免 Javascript 和 CSS 加载顺序的问题,建议将其修改成类似于 Facebook bigpipe 的解决方案,返回一个 JSON 对象 { html: ..., css: [...], js: [...] } 再进行加载顺序的控制。

Option 4: WebComponents 整合所有功能模块

Web Components 是一个 Web 标准,所以像 Angular、React/Preact、Vue 或 Hyperapp 这样的主流 JavaScript 框架都支持它们。你可以将 Web Components 视为使用开放 Web 技术创建的可重用的用户界面小部件,也许会是 Web 组件化的未来。

Web Components 由以下四种技术组成(尽管每种技术都可以独立使用):

  • 自定义元素(Custom Elements)对外提供组件的标签,实现自定义标签:可以创建自己的自定义 HTML 标签和元素。每个元素可以有自己的脚本和 CSS 样式。还包括生命周期回调,它们允许我们定义正在加载的组件特定行为。
  • HTML 模板(HTML <template>定义组件的 HTML 模板能力:一种用于保存客户端内容的机制,该内容在页面加载时不被渲染,但可以在运行时使用 JavaScript 进行实例化。可以将一个模板视为正在被存储以供随后在文档中使用的一个内容片段。
  • 影子 DOM(Shadow DOM)封装组件的内部结构,并且保持其独立性:允许我们在 Web 组件中封装 JavaScript,CSS 和 HTML。在组件内部时,这些东西与主文档的 DOM 分离。
  • HTML 导入(HTML Imports)解决组件组合和依赖加载:在微前端的上下文中,可以是包含我们要使用的组件在服务器上的远程位置。
# src/index.js
class Header extends HTMLElement {
  attachedCallback() {
    ReactDOM.render(<App />, this.createShadowRoot());
  }
}
document.registerElement('microfrontends-header', Header);
<body>
    <microfrontends-header></microfrontends-header>
    <microfrontends-products-list></microfrontends-products-list>
    <microfrontends-cart></microfrontends-cart>
</body>

在微前端的实践当中:

  • 每个团队使用各自的技术栈创建他们的组件,并把它包装到自定义元素(Custom Element)中(如 <microfrontends-header></microfrontends-header>)。
  • Web 组件就是应用程序中包含的组件的本地实现,如菜单,表单,日期选择器等。每个组件都是独立开发的,主应用程序项目利用它们组装成最终的应用程序。
  • 特定元素(标签名称,属性和事件)的 DOM 规范还可以充当跨团队之间的契约或公共 API。
  • 创建可被导入到 Web 应用程序中的可重用组件,它们就像可以导入任何网页的用户界面小部件。
<link rel="import" href="/components/microfrontends/header.html">
<link rel="import" href="/components/microfrontends/products-list.html">
<link rel="import" href="/components/microfrontends/cart.html">
  • 优点
    • 代码的可读性变得非常清晰,组件资源内部高内聚,组件资源由自身加载控制,作用域独立。
    • 功能团队可以使用组件及其功能,而不必知道实现,他们只需要能够与 HTML DOM 进行交互。
    • 使用 PubSub 机制,组件可以发布消息,其他组件可以订阅特定的主题。幸运的是浏览器内置了这个功能。比如购物车可以在 window 订阅此事件并在应该刷新其数据时得到通知。
  • 缺点
    • 可惜的是,Web 组件规范跟服务器渲染无关。没有 JavaScript,就没有所谓的自定义元素。
      • 浏览器和框架的支持不够,需要更多的 polyfills 从而影响到用户页面的加载体验。
      • 我们需要在整个 Web 应用程序上做出改变,把它们全部转换成 Web Components。
      • 社区不够活跃,Web Components 还没有真正流行起来,也许永远也不会。

不同 App 模块之间如何交互?

# angularComponent.ts
const event = new CustomEvent('addToCart', { detail: item });
window.dispatchEvent(event);
# reactComponent.js
componentDidMount() {
  window.addEventListener('addToCart', (event) => {
    this.setState({ products: [...this.state.products, event.detail] });
  }, false);
}
  • 得益于浏览器的原生 API,Custom Event 可以与其他任何技术和框架一起工作。比如,我们可以将消息从 Angular 组件发送到 React 组件。其实这也是现在 API 之间普遍使用 JSON 进行通信的原因,即使没有人使用 NodeJS 作为服务器端。
  • 但是,新的问题又出现了。我们该如何测试这种跨模块之间的交互?需要编写类似于后端微服务之间的 Contract Testing 或 Integration Testing 吗?并没有答案。

More Options…

  • 组件库 – 根据主 App 的技术栈,不同的组件和 App 模块拆分作为库的形式提供给主App,所以主 App 是由不同组件组成的。但是组件库的升级将成为一个大麻烦,比如对 Header 组件进行了更改,那么如果已经有 50 个页面使用了 Header 组件该怎么办?必须要求每一页都升级它的 Header,而且升级过程中用户还会在整个网站不同页面上看到不一致的标题。并且,在两边还必须都使用相同的技术,比如 Header 组件中使用了 ClojureScript,而 Content 组件中又用了 Elm,那么该怎么办?构建工具就必须在编译时处理不同的语言。
  • 将 App 模块作为 React 黑盒组件分发给消费者模块 – 应用程序的状态完全包含在组件中,API 只是通过 props 暴露出来。这种方式其实增加了应用程序之间的耦合,因为它迫使每个人都使用 React,甚至会使用相同版本的 React,但是这似乎也是一个比较好的折衷。
  • Edge Side Includes(ESI)/Server Side Includes(SSI) – 通过特殊的文件后缀 (shtml,inc) 或简单的标记语言来对那些可以加速和不能加速的网页中的内容片断进行描述,将每个网页划分成不同的小部分分别赋予不同的缓存控制策略。SSI / ESI 方法的缺点是,最慢的片段决定了整个页面的响应时间。

微前端的页面优化与实例

多模块页面加载问题与优化建议

  • 使用 skeleton screen 响应式布局:如上图 LinkedIn 所做的那样,首先展现给用户一个页面的空白版本,然后在这个页面中逐渐加载和填充相应的信息。否则中间的信息流部分的内容最初是空白的,然后在 JavaScript 被加载和执行过后,信息流就会因为需要占用更多的空间而推动整个页面的布局。虽然我们可以控制页面来固定中间部分的高度,但在响应式网站上,确定一个确切的高度往往很难,而且不同的屏幕尺寸可能会有所不同。但更重要的问题是,这种高度尺寸的约定会让不同团队之间产生紧密的联系,从而违背了微前端的初衷。
  • 使用浏览器异步加载加快初始渲染:对于加载成本高且难以缓存的碎片,将其从初始渲染中排除是一个好主意。比如说 LinkedIn 首页的信息流就是一个很好的例子。
  • 共享 UI 组件库保证视觉体验一致:在前端设计中,必须向用户呈现外观和感觉一致的用户界面。建议可以建立一个共享组件库(包含 CSS、字体和 JavaScript)。将这些资源托管在 CDN,每个微前端就可以在其 HTML 输出中引用它们的位置。每个组件库的版本都正确地对资源进行版本控制,而每个微前端都指定要使用的组件库的版本和显式更新依赖关系。
  • 使用集中式服务(Router)来管理 URL:可以理解为前端的 Gateway,不同的 URL 对应不同应用程序所包含的内容。建议通过一个集中式的 URLs Router 来为应用程序提供一个 API 来注册他们自己的 URL,Router 将会位于 Web 应用程序的前面,根据不同的用户请求指向不同的 App 模块组合。
  • 提取共同依赖作为 externals 加载:虽然说不同 App 模块之间不能直接共享相同的第三方模块,当我们依然可以将常用的依赖比如 lodashmoment.js等公共库,或者跨多个团队共同使用的 reactreact-dom。通过 Webpack 等构建工具就可以把打包的时候将这些共同模块排除掉,而只需要在 HTML <header> 中的 <script>中直接通过 CDN 加载 externals 依赖。
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react.min.js"
  crossorigin="anonymous"></script>
<script
  src="https://cdnjs.cloudflare.com/ajax/libs/react/16.2.0/react-dom.min.js"
  crossorigin="anonymous"></script>

微前端在 AEM(CMS)项目的应用

我们在「三靠谱」(已和谐客户名称)的 Marketplace 项目当中也曾经探索过 AEM + React 混合开发的解决方案,其中就涉及到如何在 AEM 当中嵌入 React 组件,甚至将 AEM 组件又强行转化为 React 组件进行嵌套。现在回过头来其实也算是微前端的一种实践:

  • AEM 仅仅包含网页内容,不包含 domain 相关的结构化数据。
  • React 组件被托管在 AEM 组件当中,再经由 AEM 传递给组件所需要的属性,比如 IDs 或 APIs 的 URL 等等
  • 后端微服务则包含 domain 结构化数据,由对应的 React 组件通过 Ajax 进行数据查询。
  <div id="cms-container-1">
    <div id="react-input-container"></div>
    <script>
      ReactDOM.render(React.createElement(Input, { ...injectProps }), document.getElementById('react-input-container'));
    </script>
  </div>
  <div id="cms-container-2">
    <div id="react-button-container"></div>
    <script>
      ReactDOM.render(React.createElement(Button, {}), document.getElementById('react-button-container'));
    </script>
  </div>

现成解决方案:Single-SPA “meta framework”

开源的 single-spa 自称为「元框架」,可以实现在一个页面将多个不同的框架整合,甚至在切换的时候都不需要刷新页面(支持 React、Vue、Angular 1、Angular 2、Ember 等等):

  • Build micro frontends that coexist and can each be written with their own framework.
  • Use multiple frameworks on the same page without refreshing the page (React, AngularJS, Angular, Ember, or whatever you’re using)
  • Write code using a new framework, without rewriting your existing app
  • Lazy load code for improved initial load time.
  • Hot reload entire chunks of your overall application (instead of individual files).

请看示例代码,所提供的 API 非常简单:

import * as singleSpa from 'single-spa';

const appName = 'app1';

const loadingFunction = () => import('./app1/app1.js');
const activityFunction = location => location.hash.startsWith('#/app1');

singleSpa.declareChildApplication(appName, loadingFunction, activityFunction);
singleSpa.start();
# single-spa-examples.js

declareChildApplication('navbar', () => import('./navbar/navbar.app.js'), () => true);
declareChildApplication('home', () => import('./home/home.app.js'), () => location.hash === "" || location.hash === "#");
declareChildApplication('angular1', () => import('./angular1/angular1.app.js'), hashPrefix('/angular1'));
declareChildApplication('react', () => import('./react/react.app.js'), hashPrefix('/react'));
declareChildApplication('angular2', () => import('./angular2/angular2.app.js'), hashPrefix('/angular2'));
declareChildApplication('vue', () => import('src/vue/vue.app.js'), hashPrefix('/vue'));
declareChildApplication('svelte', () => import('src/svelte/svelte.app.js'), hashPrefix('/svelte'));
declareChildApplication('preact', () => import('src/preact/preact.app.js'), hashPrefix('/preact'));
declareChildApplication('iframe-vanilla-js', () => import('src/vanillajs/vanilla.app.js'), hashPrefix('/vanilla'));
declareChildApplication('inferno', () => import('src/inferno/inferno.app.js'), hashPrefix('/inferno'));
declareChildApplication('ember', () => loadEmberApp("ember-app", '/build/ember-app/assets/ember-app.js', '/build/ember-app/assets/vendor.js'), hashPrefix('/ember'));

start();

值得一提的是,single-spa 已经进入到最新一期技术雷达的评估阶段。这意味着 single-spa 会是值得研究一番的技术,以确认它将对你产生何种影响,你应该投入一些精力来确定它是否会对你所在的组织产生影响。

摘自技术雷达:

SINGLE-SPA是一个 JavaScript 元框架,它允许我们使用不同的框架构建微前端,而这些框架可以共存于单个应用中。一般来说,我们不建议在单个应用中使用多个框架,但有时却不得不这么做。例如当你在开发遗留系统时,你希望使用现有框架的新版本或完全不同的框架来开发新功能,single-spa 就能派上用场了。鉴于很多 JavaScript框架 都昙花一现,我们需要一个解决方案来应对未来框架的变化,以及在不影响整个应用的前提下进行局部尝试。在这个方向上,single-spa 是一个不错的开始。

总结与思考:微前端的优缺点

微前端的优点

  • 敏捷性 – 独立开发和更快的部署周期:
    • 开发团队可以选择自己的技术并及时更新技术栈。
    • 一旦完成其中一项就可以部署,而不必等待所有事情完毕。
  • 降低错误和回归问题的风险,相互之间的依赖性急剧下降。
  • 更简单快捷的测试,每一个小的变化不必再触碰整个应用程序。
  • 更快交付客户价值,有助于持续集成、持续部署以及持续交付。
  • 维护和 bugfix 非常简单,每个团队都熟悉所维护特定的区域。

微前端的缺点

  • 开发与部署环境分离
    • 本地需要一个更为复杂的开发环境。
    • 每个 App 模块有一个孤立的部署周期。
    • 最终应用程序需要在同一个孤立的环境中运行。
  • 复杂的集成
    • 需要考虑隔离 JS,避免 CSS 冲突,并考虑按需加载资源
    • 处理数据获取并考虑用户的初始化加载状态
    • 如何有效测试,微前端模块之间的 Contract Testing?
  • 第三方模块重叠
    • 依赖冗余增加了管理的复杂性
    • 在团队之间共享公共资源的机制
  • 影响最终用户的体验
    • 初始 Loading 时间可能会增加
    • HTML 会需要服务器端的渲染

持续思考…

  • 变幻莫测)前端的技术选型?
    • 前端 JavaScript 框架工具穷出不穷,过几个月就要重写前端项目?比如最近又出来了声称要取代 Webpack(Parcel)和 Yarn(Turbo)的工具。伴随着前端框架的更新换代,如果整个项目一起升级/重构的话压力大、风险高,那不如拆分微前端直接支持多 framework,或者同一 framework 的不同版本?`
  • 在 Mobile/Mobile Web 上的悖论
    • 受限于 Mobile 尺寸大小,单一页面所能展现的内容本就有限。
    • 既然已经分出了不同的子页面,那何不如直接 Route 即可?
  • 合理划分的边界:DDD(领域驱动开发)
    • 最大的挑战是搞清楚如何合理拆分应用程序。
    • 糟糕的设计可能成为开发和维护的噩梦。
  • Don’t use any of this if you don’t need it
    • Do not use the ideas described here until it is needed, it will make things more complex.
    • If you are in a big company, those ideas could help you.
  • 软件架构到底在解决什么问题?—— 跨团队沟通的问题
    • 在正常情况下,每个团队拥有开发和维护其特性所需的一切,都应该有自己的能力来完成自己的特性,并最大限度地减少团队要求其他部门获得许可和/或帮助。
    • 当引入 library 或 framework 时的好处是只需要少数人讨论,而不用涉及超过 100 人的决策和他们的各种需求。这样一场大讨论不仅会耗费时间和精力,而且会迫使我们采用最不起眼的方法来选择 library,而不是选择专门针对每个 team 的问题领域的方案。

所谓架构,其实是解决人的问题;所谓敏捷,其实是解决沟通的问题;

附:参考资料

本次技术雷达「微前端」主题的宣讲 Slides 可以在我的博客找到:「技术雷达」之 Micro Frontends:微前端 – 将微服务理念扩展到前端开发 – 吕立青的博客


更多精彩洞见,请关注微信公众号:思特沃克

Share

「微前端」- 将微服务理念扩展到前端开发(理论篇)

前言

在 ThoughtWorks 正式发布的最新一期技术雷达当中,「微前端(Micro Fontends)」已经进入到试验阶段,而试验环所列出的技术是我们认为值得去追求的。理解如何建立这种能力对你所在的组织十分重要,现在就可以尝试在一个低风险的项目上试点和实践这项技术,帮助你真正了解这门技术。

摘自最新一期技术雷达:

我们已经从引入微服务架构中获得了明显的好处,微服务架构可以让团队裁剪出独立部署的交付物以及可维护的服务。不幸的是,我们还看到许多团队在后端服务之上创建了前端单体——一个单一、庞大和杂乱无绪的浏览器应用。我们首选的(经过验证的)方法是将基于浏览器的代码拆分成微前端。在这种方法中,Web 应用程序被分解为多个特性,每个特性都由不同的前后端团队拥有。这确保每个特性都独立于其他特性开发,测试和部署。这样可以使用多种技术来重新组合特性——有时候是页面,有时候是组件——最终整合成一个内聚的用户体验。

文章大纲:

  • 微前端的缘由:单体应用与微服务架构
    • 微服务架构带来了哪些好处?
    • 那么前端的现状呢? —— 臃肿的前端
  • 微前端的定义 – 将微服务理念扩展到前端开发
    • 微前端的核心思想
    • 拆分微前端所带来的好处

微前端的缘由:单体应用与微服务架构

在传统的软件开发当中,大多数软件都是单体式应用架构。在瞬息万变的商业时代背景下,企业必须学会适应这个时代的不确定性。快速试验、快速失败、更快地推出新产品以及有效改进当前产品,从而为客户提供有意义的数字体验。

而单体应用这种软件架构对于企业来说有一个致命缺点——会致使企业对于市场的响应速度变慢。企业决策者在一年内需要做的决策数量非常有限,由于存在依赖关系,其响应周期往往会变得非常漫长。每当开发或升级产品,都需要在一系列体量庞大的相关服务中同时增加新功能,这就需要所有利益相关方共同努力,以同步方式进行变更。

微服务架构带来了哪些好处?

假设服务边界已经被正确地定义为可独立运行的业务领域,并确保在微服务设计中遵循诸多最佳实践。那么至少会在以下几个方面获得显而易见的好处:

  • 复杂性:服务可以更好地分离,每一个服务都足够小,能够完成完整的定义清晰的职责;
  • 扩展性:每一个服务可以独立横向扩展以满足业务伸缩性,并减少资源的不必要消耗;
  • 灵活性:每一个服务可以独立失败,允许每个团队自主选择最适合他们的技术和基础架构;
  • 敏捷性:每一个服务都可以独立开发,测试和部署,并允许团队独立扩展和维护各自的部署服务。

每个微服务是孤立、独立的「模块」,它们共同为更高的逻辑目的服务。微服务之间通过契约彼此沟通,每个服务都负责特定的功能。这使得每个服务都能够保持简单、简洁和可测试性。

在这一基础上微服务架构允许企业更自发地采取更深远的业务决策,因为每个微服务都是独立运作的,而且每一个管理团队可以很好地控制该服务的变更。

那么前端的现状呢? —— 臃肿的前端

(图片来自:http://t.cn/R8biudo

在前端,往往由一个前端团队创建并维护一个 Web 应用程序,使用 REST API 从后端服务获取数据。这样的做法能够提供优秀的用户体验,但会导致单页面应用(SPA)不能很好地扩展和部署。在一个大公司里,单前端团队可能成为一个发展瓶颈。随着时间的推移,由一个独立团队所开发的前端层往往会越来越难以维护。特别是一个特性丰富、功能强大的前端 Web 应用程序,却位于后端微服务架构之上时。随着业务的发展,前端会变得越来越臃肿,一个项目可能会有 90% 的前端代码,却只有非常薄的后端,这种情况在 Serverless 架构的背景下还会愈演愈烈。

微前端的定义 – 将微服务理念扩展到前端开发

微前端(Micro Frontends)这个术语其实就是微服务的衍生物。将微服务理念扩展到前端开发,同时构建多个完全自治、松耦合的 App 模块(服务),其中每个 App 模块只负责特定的 UI 元素和功能。

如果我们看到微服务提供给后端的好处,就可以更进一步将这些好处应用到前端。与此同时,在设计微服务的时候,就可以考虑不仅要完成后端逻辑,而且还要完成前端的视觉部分。而微前端与微服务的许多要求也是一致的:监控、日志、HealthCheck、Analytics 等等。

微前端的核心思想

  • Be Technology Agnostic:每个团队都应该能够选择并升级他们的技术栈,而不必与其他团队协调。自定义元素(后面会具体提到)是隐藏实现细节的好方法,同时为其他人提供公共接口。
  • Isolate Team Code:即使所有团队使用相同的框架,也不要共享运行时。构建独立的应用程序。不要依赖共享状态或全局变量。
  • Establish Team Prefixes:相互约定命名隔离。为 CSS、浏览器事件、Local Storage 和 Cookies 制定命名空间,以避免冲突,明确其所有权。
  • Favor Native Browser Features over Custom APIs:使用浏览器事件进行通信,而不是构建全局的 PubSub 系统。如果确实需要构建跨团队 API,请尽量保持简单。(与框架无关,可使用 CustomEvent)
  • Build a Resilient Site:即使 JavaScript 失败或尚未执行,Web 应用程序的功能仍应有效。可以使用通用渲染和渐进增强来提高用户的感知性能。

拆分微前端所带来的好处

拆分微前端能使各个前端团队按照自己的步调进行迭代,并随时准备就绪进入可发布状态,并隔离相互依赖所产生的风险,与此同时也更容易尝试新技术。

  • Web 应用程序被分解成独立的特征,并且每个特征都由不同的团队拥有,前端到后端。这确保了每个功能都是独立于其他功能开发、测试和部署的。
  • 将网站或 Web 应用程序视为由独立团队拥有的功能组合。每个团队都有一个独特的业务或关注点确定的任务。
  • 每一个团队是跨职能的,从数据库到用户界面端到端地开发其功能/特性。
  • 所有前端功能(身份验证,库存,购物车等)都是 Web 应用程序的一部分,并与后端(大部分时间通过 HTTP)进行通信,并将其分解为微服务。
  • 可以同时拥有后端、前端、数据访问层和数据库,即一个服务子域所需的所有内容。
  • 查找线上 bug、测试、框架迭代,甚至语言、代码隔离与责任和其他事情变得更容易处理。
  • 我们不得不付出的代价是部署,但是容器(Docker 和 Rocket)以及不可变服务器使得这种情况也得到了极大的改善。

续:微前端的实践方案

本文的下篇即技术雷达之「微前端」- 将微服务理念扩展到前端开发(实战篇)将逐一介绍微前端实战中超过 4 种的可选实践方案,并对多模块页面加载可能出现的问题与优化给出建议,最后对微前端的优缺点进行总结并提出了一些新的思考。


更多精彩洞见,请关注微信公众号:思特沃克

Share

微服务 | Martin Fowler

作者:Martin Fowler & James Lewis 译者:伍斌

微服务

“微服务架构”这一术语在前几年横空出世,用于描述这样一种特定的软件设计方法,即以若干组可独立部署的服务的方式进行软件应用系统的设计。尽管这种架构风格尚无明确的定义,但其在下述方面还是存在一定的共性,即围绕业务功能的组织、自动化部署、端点智能、以及在编程语言和数据方面进行去中心化的控制。

本文目录

  • 微服务架构的九大特性
    • 特性一:“组件化”与“多服务”
    • 特性二:围绕“业务功能”组织团队
    • 特性三:“做产品”而不是“做项目”
    • 特性四:“智能端点”与“傻瓜管道”
    • 特性五:“去中心化”地治理技术
    • 特性六:“去中心化”地管理数据
    • 特性七:“基础设施”自动化
    • 特性八:“容错”设计
    • 特性九:“演进式”设计
  • 未来的方向是“微服务”吗?

“微服务”——这是在“软件架构”这条熙熙攘攘的大街上出现的又一个新词语。我们很容易对它不屑一顾,但是这个小小的术语却描述了一种引人入胜的软件系统风格。在近几年中,我们越来越多的看到许多项目使用了这种风格,而且就目前来说结果都是不错的,以至于许多ThoughtWorker都把它看作构建企业应用系统的默认风格。然而,很不幸的是,我们找不到有关它的概要信息,即什么是微服务风格,以及如何设计微服务风格的架构。

简而言之,微服务架构风格[1]这种开发方法,是以开发一组小型服务的方式来开发一个独立的应用系统。其中每个小型服务都运行在自己的进程中,并经常采用HTTP资源API这样轻量的机制来相互通信。这些服务围绕业务功能进行构建,并能通过全自动的部署机制来进行独立部署。这些微服务可以使用不同的语言来编写,并且可以使用不同的数据存储技术。对这些微服务,我们仅做最低限度的集中管理。

在开始介绍微服务风格之前,将其与单块(monolithic)风格进行对比还是很有意义的:一个单块应用系统是以一个单个单元的方式来构建的。企业应用系统经常包含三个主要部分:客户端用户界面、数据库和服务端应用系统。客户端用户界面包括HTML页面和运行在用户机器的浏览器中的JavaScript。数据库中包括许多表,这些表被插入一个公共的且通常为关系型的数据库管理系统中。这个服务端的应用系统就是一个单块应用——一个单个可执行的逻辑程序[2]。对于该系统的任何改变,都会涉及构建和部署上述服务端应用系统的一个新版本。

这样的单块服务器是构建上述系统的一种自然的方式。处理用户请求的所有逻辑都运行在一个单个的进程内,因此能使用编程语言的基本特性,来把应用系统划分为类、函数和命名空间。通过精心设计,得以在开发人员的笔记本电脑上运行和测试这样的应用系统,并且使用一个部署流水线来确保变更被很好地进行了测试,并被部署到生产环境中。通过负载均衡器运行许多实例,来将这个单块应用进行横向扩展。

单块应用系统可以被成功地实现,但是渐渐地,特别是随着越来越多的应用系统正被部署到云端,人们对它们开始表现出不满。软件变更受到了很大的限制,应用系统中一个很小部分的一处变更,也需要将整个单块应用系统进行重新构建和部署。随着时间的推移,单块应用逐渐难以保持一个良好的模块化结构,这使得它变得越来越难以将一个模块的变更所产生的影响控制在该模块内。当对系统进行扩展时,不得不扩展整个应用系统,而不能仅扩展该系统中需要更多资源的那些部分。

图1: 单块应用和微服务

这些不满催生出了微服务架构风格:以构建一组小型服务的方式来构建应用系统。除了这些服务能被独立地部署和扩展之外,每一个服务还能提供一个稳固的模块边界,甚至能允许使用不同的编程语言来编写不同的服务。这些服务也能被不同的团队来管理。

我们并不认为微服务风格是一个新颖或创新的概念,它的起源至少可以追溯到Unix的设计原则。但是我们觉得,考虑微服务架构的人还不够多,并且如果对其加以使用,许多软件的开发工作能变得更好。

微服务架构的九大特性

虽然不能说存在微服务架构风格的正式定义,但是可以尝试描述我们所见到的、能够被贴上“微服务”标签的那些架构的共性。下面所描述的这些共性,并不是所有的微服务架构都完全具备,但是我们确实期望大多数微服务架构都具备这些共性中的大多数特性。尽管我们两位作者已经成为这个相当松散的社区中的活跃成员,但我们的本意还是描述我们两人在自己所在和所了解的团队工作中所看到的情况。特别要指出,我们不会制定大家需要遵循的微服务的定义。

特性一:“组件化”与“多服务”

自我们从事软件行业以来,发现大家都有“把组件插在一起来构建系统”的愿望,就像在物理世界中所看到的那样。在过去几十年中,我们已经看到,在公共软件库方面已经取得了相当大的进展,这些软件库是大多数编程语言平台的组成部分。

当谈到组件时,会碰到一个有关定义的难题,即什么是组件?我们的定义是:一个组件就是一个可以独立更换和升级的软件单元。

微服务架构也会使用软件库,但其将自身软件进行组件化的主要方法是将软件分解为诸多服务。我们将软件库(libraries)定义为这样的组件,即它能被链接到一段程序,且能通过内存中的函数来进行调用。然而,服务(services)是进程外的组件,它们通过诸如web service请求或远程过程调用这样的机制来进行通信(这不同于许多面向对象的程序中的service object概念[3])。

以使用服务(而不是以软件库)的方式来实现组件化的一个主要原因是,服务可被独立部署。如果一个应用系统[4]由在单个进程中的多个软件库所组成,那么对任一组件做一处修改,都不得不重新部署整个应用系统。但是如果该应用系统被分解为多个服务,那么对于一个服务的多处修改,仅需要重新部署这一个服务。当然这也不是绝对的,一些变更服务接口的修改会导致多个服务之间的协同修改。但是一个良好的微服务架构的目的,是通过内聚的服务边界和服务协议方面的演进机制,来将这样的修改变得最小化。

以服务的方式来实现组件化的另一个结果,是能获得更加显式的(explicit)组件接口。大多数编程语言并没有一个良好的机制来定义显式的发布接口。通常情况下,这样的接口仅仅是文档声明和团队纪律,来避免客户端破坏组件的封装,从而导致组件间出现过度紧密的耦合。通过使用显式的远程调用机制,服务能更容易地规避这种情况。

如此使用服务,也会有不足之处。比起进程内调用,远程调用更加昂贵。所以远程调用API接口必须是粗粒度的,而这往往更加难以使用。如果需要修改组件间的职责分配,那么当跨越进程边界时,这种组件行为的改动会更加难以实现。

近似地,我们可以把一个个服务映射为一个个运行时的进程,但这仅仅是一个近似。一个服务可能包括总是在一起被开发和部署的多个进程,比如一个应用系统的进程和仅被该服务使用的数据库。

特性二:围绕“业务功能”组织团队

当在寻求将一个大型应用系统分解成几部分时,公司管理层往往会聚焦在技术层面上,这就意味着要组建用户界面团队、服务器端团队和数据库团队。当团队沿着这些技术线分开后,即使要实现软件中一个简单的变更,也会发生跨团队的项目时延和预算审批。在这种情况下,聪明的团队会进行局部优化,“两害相权取其轻”,来直接把代码逻辑塞到他们能访问到的任意应用系统中。换句话说,这种情况会导致代码逻辑散布在系统各处。这就是康威定律[5]的鲜活实例。

任何设计(广义上的)系统的组织,都会产生这样一个设计,即该设计的结构与该组织的沟通结构相一致。——梅尔文•康威(Melvyn Conway), 1967年

图2:康威定律在起作用

微服务使用不同的方法来分解系统,即根据业务功能(business capability)来将系统分解为若干服务。这些服务针对该业务领域提供多层次、广泛的软件实现,包括用户界面、持久性存储以及任何对外的协作性操作。因此,团队是跨职能的,它拥有软件开发所需的全方位的技能:用户体验、数据库和项目管理。

图3:被团队边界所强化的服务边界

以上述方式来组织团队的公司是www.comparethemarket.com。跨职能团队负责构建和运维每个产品,而每个产品被拆分为多个独立的服务,彼此通过一个消息总线来通信。

一个微服务应该有多大?

尽管许多人已经习惯于用“微服务”来概括描述这种这种架构风格,但是这个名字确实会不幸地引发大家对服务规模的关注,并且产生有关什么是“微”的争论。在与微服务从业者的交谈中,我们看到了有关服务的一系列规模。所听到的最大的一个服务规模,是遵循了亚马逊的“两个比萨团队”(即一个团队可以被两个比萨所喂饱)的理念所形成的,这意味着这个团队不会多于12人。对于规模较小的服务,我们已经看到一个6人的团队在支持6个服务。

这引出了一个问题,即“每12人做一个服务”和“每人做一个服务”这样有关服务规模的差距,是否已经大到不能将两者都纳入微服务之下?此时,我们认为最好还是把它们归为一类,但是随着探索的深入,我们将来极有可能会改变主意。

大型单块应用系统也可以始终根据业务功能来进行模块化设计,虽然这并不常见。当然,我们会敦促构建单块应用系统的大型团队根据业务线来将自己分解为若干小团队。在这方面,我们已经看到的主要问题是,他们往往是一个团队包含了太多的业务功能。如果这个“单块”跨越了许多模块的边界,那么这个团队的每一个成员都难以记住所有模块的业务功能。此外,我们看到这些模块的边界需要大量的团队纪律来强制维持。而实现组件化的服务所必要的更加显式的边界,能更加容易地保持团队边界的清晰性。

特性三:“做产品”而不是“做项目”

我们所看的大部分应用系统的开发工作都使用项目模型:目标是交付某一块软件,之后就认为完工了。一旦完工后,软件就被移交给维护团队,接着那个构建该软件的项目团队就会被解散。

微服务的支持者们倾向于避免使用上述模型,而宁愿采纳“一个团队在一个产品的整个生命周期中都应该保持对其拥有”的理念。通常认为这一点源自亚马逊的“谁构建,谁运行”的理念,即一个开发团队对一个在生产环境下运行的软件负全责。这会使开发人员每天都关注软件是如何在生产环境下运行的,并且增进他们与用户的联系,因为他们必须承担某些支持工作。

这样的“产品”理念,是与业务功能的联动绑定在一起的。它不会将软件看作是一个待完成的功能集合,而是认为存在这样一个持续的关系,即软件如何能助其客户来持续增进业务功能。

当然,单块应用系统的开发工作也可以遵循上述“产品”理念,但是更细粒度的服务,能让服务的开发者与其用户之间的个人关系的创建变得更加容易。

特性四:“智能端点”与“傻瓜管道”

当在不同的进程之间构建各种通信结构时,我们已经看到许多产品和方法,来强调将大量的智能特性纳入通信机制本身。其中一个典型例子,就是“企业服务总线”(Enterprise Service Bus, ESB)。ESB产品经常包括高度智能的设施,来进行消息的路由、编制(choreography)、转换,并应用业务规则。

微服务社区主张采用另一种做法:智能端点(smart endpoints)和傻瓜管道(dumb pipes)。使用微服务所构建的各个应用的目标,都是尽可能地实现“高内聚和低耦合”——他们拥有自己的领域逻辑,并且更像是经典Unix的“过滤器”(filter)那样来工作——即接收一个请求,酌情对其应用业务逻辑,并产生一个响应。这些应用通过使用一些简单的REST风格的协议来进行编制,而不去使用诸如下面这些复杂的协议,即”WS-编制”(WS-Choreography)、BPEL或通过位于中心的工具来进行编排(orchestration)。

微服务最常用的两种协议是:带有资源API的HTTP“请求-响应”协议,和轻量级的消息发送协议[6]。对于前一种协议的最佳表述是:

成为Web,而不是躲着Web (Be of the web, not behind the web)——Ian Robinson

这些微服务团队在开发中,使用在构建万维网(world wide web)时所使用的原则和协议(并且在很大程度上,这些原则和协议也是在构建Unix系统时所使用的)。那些被使用过的HTTP资源,通常能被开发或运维人员轻易地缓存起来。

最常用的第二种协议,是通过一个轻量级的消息总线来进行消息发送。此时所选择的基础设施,通常是“傻瓜”(dumb)型的(仅仅像消息路由器所做的事情那样傻瓜)——像RabbitMQ或ZeroMQ那样的简单实现,即除了提供可靠的异步机制(fabric)以外不做其他任何事情——智能功能存在于那些生产和消费诸多消息的各个端点中,即存在于各个服务中。

在一个单块系统中,各个组件在同一个进程中运行。它们相互之间的通信,要么通过方法调用,要么通过函数调用来进行。将一个单块系统改造为若干微服务的最大问题,在于对通信模式的改变。仅仅将内存中的方法调用转换为RPC调用这样天真的做法,会导致微服务之间产生繁琐的通信,使得系统表现变糟。取而代之的是,需要用更粗粒度的协议来替代细粒度的服务间通信。

特性五:“去中心化”的治理技术

使用中心化的方式来对开发进行治理,其中一个后果,就是趋向于在单一技术平台上制定标准。经验表明,这种做法会带来局限性——不是每一个问题都是钉子,不是每一个方案都是锤子。我们更喜欢根据工作的不同来选用合理的工具。尽管那些单块应用系统能在一定程度上利用不同的编程语言,但是这并不常见。

如果能将单块应用的那些组件拆分成多个服务,那么在构建每个服务时,就可以有选择不同技术栈的机会。想要使用Node.js来搞出一个简单的报表页面?尽管去搞。想用C++来做一个特别出彩的近乎实时的组件?没有问题。想要换一种不同风格的数据库,来更好地适应一个组件的读取数据的行为?可以重建。

微服务和SOA

当我们谈起微服务时,一个常见的问题就会出现:是否微服务仅仅是十多年前所看到的“面向服务的架构”(Service Oriented Architecture, SOA)?这样问是有道理的,因为微服务风格非常类似于一些支持SOA的人所赞成的观点。然而,问题在于SOA这个词儿意味着太多不同的东西。而且大多数时候,我们所遇到的某些被称作”SOA”的事物,明显不同于本文所描述的风格。这通常由于它们专注于ESB,来集成各个单块应用。

特别地,我们已经看到如此之多的面向服务的拙劣实现——从将系统复杂性隐藏于ESB中的趋势[7],到花费数百万进行多年却没有交付任何价值的失败项目,到顽固抑制变化发生的中心化技术治理模型——以至于有时觉得其所造成的种种问题真的不堪回首。

当然,在微服务社区投入使用的许多技术,源自各个开发人员将各种服务集成到各个大型组织的经验。“容错读取”(Tolerant Reader)模式就是这样一个例子。对于Web的广泛使用,使得人们不再使用一些中心化的标准,而使用一些简单的协议。坦率地说,这些中心化的标准,其复杂性已经达到令人吃惊的程度。(任何时候,如果需要一个本体来管理其他各个本体,那么麻烦就大了。)

这种常见的SOA表现,已使得一些微服务的倡导者完全拒绝将自己贴上SOA的标签。尽管其他人会将微服务看作是SOA的一种形式[8],也许微服务就是以正确的形式来实现面向服务的SOA。不管是哪种情况,SOA意味着如此之多的不同事物,这表明用一个更加干净利落的术语来命名这种架构风格是很有价值的。

当然,仅仅能做事情,并不意味着这些事情就应该被做——不过用微服务的方法把系统进行拆分后,就拥有了技术选型的机会。

相比选用业界一般常用的技术,构建微服务的那些团队更喜欢采用不同的方法。与其选用一组写在纸上已经定义好的标准,他们更喜欢编写一些有用的工具,来让其他开发者能够使用,以便解决那些和他们所面临的问题相似的问题。这些工具通常源自他们的微服务实施过程,并且被分享到更大规模的组织中,这种分享有时会使用内部开源的模式来进行。事实上,现在git和github已经成为首选版本控制系统。在企业内部,开源的做法正在变得越来越普遍。

Netflix公司是遵循上述理念的好例子。将实用且经过实战检验的代码以软件库的形式共享出来,能鼓励其他开发人员以相似的方式来解决相似的问题,当然也为在需要的时候选用不同的方案留了一扇门。共享软件库往往聚焦于解决这样的常见问题,即数据存储、进程间的通信和下面要进一步讨论的基础设施的自动化。

对于微服务社区来说,日常管理开销这一点不是特别吸引人。这并不是说这个社区并不重视服务契约。恰恰相反,它们在社区里出现得更多。这正说明这个社区正在寻找对其进行管理的各种方法。像“容错读取”和“消费者驱动的契约”(Consumer-Driven Contracts)这样的模式,经常被运用到微服务中。这些都有助于服务契约进行独立演进。将执行“消费者驱动的契约”做为软件构建的一部分,能增强开发团队的信心,并提供所依赖的服务是否正常工作的快速反馈。实际上,我们了解到一个在澳洲的团队就是使用“消费者驱动的契约”来驱动构建多个新服务的。他们使用了一些简单的工具,来针对每一个服务定义契约。甚至在新服务的代码编写之前,这件事就已经成为自动化构建的一部分了。接下来服务仅被构建到刚好能满足契约的程度——这是一个在构建新软件时避免YAGNI[9]困境的优雅方法。这些技术和工具在契约周边生长出来,由于减少了服务之间在时域(temporal)上的耦合,从而抑制了对中心契约管理的需求。

多种编程语言,多种选择可能

做为一个平台,JVM的发展仅仅是一个将各种编程语言混合到一个通用平台的最新例证。近十年以来,通过在平台外层实现更高层次的编程语言,来利用更高层次的抽象,已经成为一个普遍做法。同样,在平台底层以更低层次的编程语言编写性能敏感的代码也很普遍。然而,许多单块系统并不需要这种级别的性能优化,另外DSL和更高层次的抽象也不常用(这令我们感到失望)。相反,许多单块应用通常就使用单一编程语言,并且有对所使用的技术数量进行限制的趋势[10]。

或许去中心化地治理技术的极盛时期,就是亚马逊的“谁构建,谁运行”的理念开始普及的时候。各个团队负责其所构建的软件的所有工作,其中包括7×24地对软件进行运维。“将运维这一级别的职责下放到团队”这种做法,目前绝对不是主流。但是我们确实看到越来越多的公司,将运维的职责交给各个开发团队。Netflix就是已经形成这种风气的另一个组织[11]。避免每天凌晨3点被枕边的寻呼机叫醒,无疑是在程序员编写代码时令其专注质量的强大动力。而这些想法,与那些传统的中心化技术治理的模式具有天壤之别。

特性六:“去中心化”地管理数据

去中心化地管理数据,其表现形式多种多样。从最抽象的层面看,这意味着各个系统对客观世界所构建的概念模型各不相同。当在一个大型的企业中进行系统集成时,这是一个常见的问题。比如对于“客户”这个概念,从销售人员的视角看,就与从支持人员的视角看有所不同。从销售人员的视角所看到的一些被称之为“客户”的事物,或许在支持人员的视角中根本找不到。而那些在两个视角中都能看到的事物,或许各自具有不同的属性。更糟糕的是,那些在两个视角中具有相同属性的事物,或许在语义上有微妙的不同。

上述问题在不同的应用程序之间经常出现,同时也会出现在这些应用程序内部,特别是当一个应用程序被分成不同组件时就会出现。思考这类问题的一个有效方法,就是使用领域驱动设计(Domain-Driven Design, DDD)中的“限界上下文”(Bounded Context)的概念。DDD将一个复杂的领域划分为多个限界上下文,并且将其相互之间的关系用图画出来。这一划分过程对于单块和微服务架构两者都是有用的,而且就像前面有关“业务功能”一节中所讨论的那样,在服务和各个限界上下文之间所存在的自然的联动关系,有助于澄清和强化这种划分。

“实战检验”的标准与“强制执行”的标准

微服务的下述做法有点泾渭分明的味道,即他们趋向于避开被那些企业架构组织所制定的硬性实施标准,而愉快地使用甚至传播一些开放标准,比如HTTP、ATOM和其他微格式的协议。

这里的关键区别是,这些标准是如何被制定以及如何被实施的。像诸如IETF这样的组织所管理的各种标准,只有达到下述条件才能称为标准,即该标准在全球更广阔的地区有一些正在运行的实现案例,而且这些标准经常源自一些成功的开源项目。

这些标准组成了一个世界,它区别于来自下述另一个世界的许多标准,即企业世界。企业世界中的标准,经常由这样特点的组织来开发,即缺乏用较新技术进行编程的经验,或受到供应商的过度影响。

如同在概念模型上进行去中心化的决策一样,微服务也在数据存储上进行去中心化的决策。尽管各个单块应用更愿意在逻辑上各自使用一个单独的数据库来持久化数据,但是各家企业往往喜欢一系列单块应用共用一个单独的数据库——许多这样的决策是被供应商的各种版权商业模式所驱动出来的。微服务更喜欢让每一个服务来管理其自有数据库。其实现可以采用相同数据库技术的不同数据库实例,也可以采用完全不同的数据库系统。这种方法被称作“多语种持久化”(Polyglot Persistence)。在一个单块系统中也能使用多语种持久化,但是看起来这种方法在微服务中出现得更加频繁。

图4:微服务更喜欢让每一个服务来管理其自有数据库

在各个微服务之间将数据的职责进行“去中心化”的管理,会影响软件更新的管理。处理软件更新的常用方法,是当更新多个资源的时候,使用事务来保证一致性。这种方法经常在单块系统中被采用。

像这样使用事务,有助于保持数据一致性。但是在时域上会引发明显的耦合,这样一来,在多个服务之间处理事务时会出现一致性问题。分布式事务实现难度之大是不必多言的。为此,微服务架构更强调在各个服务之间进行“无事务”的协调。这源自微服务社区明确地认识到下述两点,即数据一致性可能只要求数据在最终达到一致,并且一致性问题能够通过补偿操作来进行处理。

对于许多开发团队来说,选择这种方式来管理数据的“非一致性”,是一个新的挑战。但这通常也符合在商业上的实践做法。通常情况下,为了快速响应需求,商家们都会处理一定程度上的数据“非一致性”,通过做某种反向过程来进行错误处理。只要修复错误的成本低于“保持更大的数据一致性却导致丢了生意所产生”的成本相比,那么进行这种“非一致性”地数据管理就是值得的。

特性七:“基础设施”自动化

基础设施自动化技术在过去几年里已经得到长足的发展。云的演进,特别是AWS的发展,已经降低了构建、部署和运维微服务的操作复杂性。

许多使用微服务构建的产品和系统,正在被这样的团队所构建,即他们都具备极其丰富的“持续交付”和其前身“持续集成”的经验。用这种方法构建软件的各个团队,广泛采用了基础设施的自动化技术。如下图的构建流水线所示:

图5:基本的构建流水线

由于本文并不是一篇有关持续交付的文章,所以下面仅提请大家注意两个持续交付的关键特点。为了尽可能地获得对正在运行的软件的信心,需要运行大量的自动化测试。让可工作的软件达到“晋级”(Promotion)状态、从而“推上”流水线,就意味着可以在每一个新的环境中,对软件进行自动化部署。

一个单块应用程序,能够相当愉快地在上述各个环境中,被构建、测试和推送。其结果是,一旦在下述工作中进行了投入,即针对一个单块系统将其通往生产环境的通道进行自动化,那么部署更多的应用系统似乎就不再可怕。记住,持续交付的目的之一,是让“部署”工作变得“无聊”。所以不管是一个还是三个应用系统,只要部署工作依旧很“无聊”,那么就没什么可担心的了[12]。

让“沿着正确的方向做事”更容易

那些因实现持续交付和持续集成所增加的自动化工作的副产品,是一些对开发和运维人员有用的工具。现在,能完成下述工作的工具已经相当常见了,即创建工件(artefacts)、管理代码库、启动一些简单的服务、或增加标准的监控和日志功能。Web上最好的例子可能是Netflix提供的一套开源工具集,但也有其他一些好工具,包括我们已经广泛使用的Dropwizard

我们所看到的各个团队在广泛使用基础设施自动化实践的另一个领域,是在生产环境中管理各个微服务。与前面我们对比单块系统和微服务所说的正相反,只要部署工作很无聊,那么在这一点上单块系统和微服务就没什么区别。然而,两者在运维领域的情况却截然不同。

图6:两者的模块部署经常会有差异

特性八:“容错”设计

使用各个微服务来替代组件,其结果是各个应用程序需要被设计的能够容忍这些服务所出现的故障。如果服务提供方不可用,那么任何对该服务的调用都会出现故障。客户端要尽可能优雅地应对这种情况。与一个单块设计相比,这是一个劣势。因为在处理这种情况时会引入额外的复杂性。为此,各个微服务团队在不断地反思:这些服务故障是如何影响用户体验的。Netflix公司所研发的开源测试工具Simian Army,能够诱导服务发生故障,甚至能诱导一个数据中心在工作日发生故障,来测试该应用的弹性和监控能力。

这种在生产环境中所进行的自动化测试,足以让大多数运维组织兴奋得浑身颤栗,就像即将迎来一周的长假那样。这并不是说单块架构风格不能构建先进的监控系统——只是根据我们的经验,这在单块系统中并不常见罢了。

“断路器”与“可随时上线的代码”

断路器”(Circuit Breaker)一词与其他一些模式一起出现在《Release It!》一书中,例如隔板(Bulkhead)和超时(Timeout)。当构建彼此通信的应用系统时,将这些模式加以综合运用就变得至关重要。Netflix公司的这篇很精彩的博客解释了这些模式是如何应用的。

因为各个服务可以在任何时候发生故障,所以下面两件事就变得很重要,即能够快速地检测出故障,而且在可能的情况下能够自动恢复服务。各个微服务的应用都将大量的精力放到了应用程序的实时监控上,来检查“架构元素指标”(例如数据库每秒收到多少请求)和“业务相关指标”(例如系统每分钟收到多少订单)。当系统某个地方出现问题,语义监控系统能提供一个预警,来触发开发团队进行后续的跟进和调查工作。

这对于一个微服务架构是尤其重要的,因为微服务对于服务编制(choreography)和事件协作的偏好,会导致“突发行为”。尽管许多权威人士对于偶发事件的价值持积极态度,但事实上,“突发行为”有时是一件坏事。在能够快速发现有坏处的“突发行为”并进行修复方面,监控是至关重要的。

单块系统也能构建的像微服务一样来实现透明的监控系统——实际上,它们也应该如此。差别是,绝对需要知道那些运行在不同进程中的服务,在何时断掉了。而如果在同一个进程内使用软件库的话,这种透明的监控系统就用处不大了。

“同步调用”有害

一旦在一些服务之间进行多个同步调用,就会遇到宕机的乘法效应。简而言之,这意味着整个系统的宕机时间,是每一个单独模块各自宕机时间的乘积。此时面临着一个选择:是让模块之间调用异步,还是去管理宕机时间?在英国卫报网站,他们在新平台上实现了一个简单的规则——每一个用户请求都对应一个同步调用。然而在Netflix公司,他们重新设计的平台API将异步性构建到API的机制(fabric)中。

那些微服务团队希望在每一个单独的服务中,都能看到先进的监控和日志记录装置。例如显示“运行/宕机”状态的仪表盘,和各种运维、业务相关的指标。另外我们经常在工作中会碰到这样一些细节,即断路器的状态、当前的吞吐率和延迟,以及其他一些例子。

特性九:“演进式”设计

那些微服务的从业者们,通常具有演进式设计的背景,而且通常将服务的分解,视作一个额外的工具,来让应用开发人员能够控制应用系统中的变化,而无须减少变化的发生。控制变化并不一定意味着要减少变化——在正确的态度和工具的帮助下,软件中的变化也可以发生得频繁、快速且得到良好的控制。

每当要试图将软件系统分解为各个组件时,就会面临这样的决策,即如何进行切分——我们决定切分应用系统时应该遵循的原则是什么?一个组件的关键属性,是具有独立更换和升级的特点[13]——这意味着,需要寻找这些点,即想象着能否在其中一个点上重写该组件,而无须影响该组件的其他合作组件。事实上,许多做微服务的团队会更进一步,他们明确地预期许多服务将来会报废,而不是守着这些服务做长期演进。

英国卫报网站是一个好例子。原先该网站是一个以单块系统的方式来设计和构建的应用系统,然而它已经开始向微服务方向进行演进了。原先的单块系统依旧是该网站的核心,但是在添加新特性时他们愿意以构建一些微服务的方式来进行添加,而这些微服务会去调用原先那个单块系统的API。当开发那些本身就带有临时性特点的新特性时,这种方法就特别方便,例如开发报道一个体育赛事的专门页面。当使用一些快速的开发语言时,这样的网站页面就能被快速地整合起来。而一旦赛事结束,这样页面就可以被删除。在一个金融机构中,我们已经看到了一些相似的做法,即针对一个市场机会,一些新的服务可以被添加进来。然后在几个月甚至几周之后,这些新服务就作废了。

这种强调“可更换性”的特点,是模块化设计一般性原则的一个特例,通过“变化模式”(pattern of change)[14]来驱动模块化的实现。大家都愿意将那些能在同时发生变化的东西,放到同一个模块中。系统中那些很少发生变化的部分,应该被放到不同的服务中,以区别于那些正在经历大量变动(churn)的部分。当发现两个服务需要被同时、反复变更,就意味着它们两个需要被合并。

把一个个组件放入一个个服务中,提高了软件发布精细化的程度。对于一个单块系统,任何变化都需要做一次整个应用系统的全量构建和部署。然而,对于一个个微服务来说,只需要重新部署修改过的那些服务就够了。这能简化并加快发布过程。但缺点是:必须要考虑当一个服务发生变化时,依赖它并对其进行消费的其他服务将无法工作。传统的集成方法是使用版本化来解决这个问题。但在微服务世界中,大家更喜欢将版本化作为最后万不得已的手段来使用。我们可以通过下述方法来避免许多版本化的工作,即把各个服务设计得尽量能够容错,来应对其所依赖的服务所发生的变化。

未来的方向是“微服务”吗?

我们写这篇文章的主要目的,是解释有关微服务的主要思路和原则。在花了一点时间做了这件事后,我们清楚地认识到,微服务架构风格是一个重要的理念——在研发企业应用系统时,值得对它进行认真考虑。我们最近已经使用这种风格构建了一些系统,并且了解到其他一些团队也赞同并正在使用这种方法。

我们所了解到的那些在某种程度上可以被称作这种架构风格的实践先驱包括:亚马逊、Netflix、英国卫报英国政府数字化服务中心、realestate.com.au、Forward和comparethemarket.com。2013年的技术大会圈子充满了各种各样的、正在转向可归类为微服务的公司案例——包括Travis CI。另外还有大量的组织,它们长期以来一直在做着我们认为可以归类为微服务的产品,却从未使用过这个名字(这通常被标记为SOA——尽管正如我们所说,SOA会表现出各种自相矛盾的形式[15])。

尽管有这些正面的经验,但这并不意味着我们确信微服务是软件架构未来的方向。尽管到目前为止,与单块应用系统相比,我们对于所经历过的微服务的评价是积极的,但是我们也意识到这样的事实,即能供我们做出完整判断的时间还不够长。

通常,架构决策所产生的真正效果,只有在该决策做出若干年后才能真正显现。我们已经看到由带着强烈的模块化愿望的优秀团队所做的一些项目,最终构建出一个单块架构,并在几年之内不断腐化。许多人认为,如果使用微服务就不大可能出现这种腐化,因为服务的边界是明确的,而且难以随意搞乱。然而,对于那些开发时间足够长的各种系统,除非我们已经见识得足够多,否则我们无法真正评价微服务架构是如何成熟的。

我们的同事Sam Newman花了2014年的大部分时间撰写了一本书,来记述我们构建微服务的经验。如果想对这个话题进行更深入的了解,下一步就应该是阅读这本书。

有人觉得微服务或许很难成熟起来,这当然是有原因的。在组件化上所做的任何工作的成功度,取决于软件与组件的匹配程度。准确地搞清楚某个组件的边界位置应该出现在哪里,是一件困难的工作。演进式设计承认难以对边界进行正确定位,所以它将工作的重点放到了易于对边界进行重构之上。但是当各个组件成为各个进行远程通信的服务后,比起在单一进程内调用各个软件库,此时的重构就变得更加困难。跨越服务边界的代码移动就变得困难起来。接口的任何变化,都需要在其各个参与者之间进行协调。向后兼容的层次也需要被添加进来。测试也会变得更加复杂。

另一个问题是,如果这些组件不能干净利落地组合成一个系统,那么所做的一切工作,仅仅是将组件内的复杂性转移到组件之间的连接之上。这样做的后果,不仅仅是将复杂性搬了家,它还将复杂性转移到那些不再明确且难以控制的边界之上。在观察一个小型且简单的组件内部时,人们很容易觉得事情已经变得更好了,然而他们却忽视了服务之间杂乱的连接。

最后,还要考虑团队成员的技能水平。新技术往往会被技术更硬的团队所采用。对于技术更加过硬的团队而更有效的一项技术,不一定适用于技术略逊一筹的团队。我们已经看到大量这样的案例,那些技术略逊一筹的团队构建出了杂乱的单块架构。当这种杂乱发生到微服务身上时,会出现什么情况?这需要花时间来观察。一个糟糕的团队,总会构建一个糟糕的系统——在这种情况下,很难讲微服务究竟是减少了杂乱,还是让事情变得更糟。

我们听到一个合理的说法:不要一上来就以微服务架构做为起点。相反,要用一个单块系统做为起点,并保持其模块化。当这个单块系统出现了问题后,再将其分解为微服务。(尽管这个建议并不理想,因为一个良好的单一进程内的接口,通常不是一个良好的服务接口。)

因此,我们持谨慎乐观的态度来撰写此文。到目前为止,我们已经看到足够多的有关微服务风格的内容,并且觉得这是一条值得去跋涉的道路。我们不能肯定地说,道路的尽头在哪里。但是,软件开发的挑战之一,就是只能基于“目前手上拥有但还不够完善”的信息来做出决策。

若欲获取最新参考资料列表以得到更多信息,请参见微服务资源指南:http://martinfowler.com/microservices/

注:

[1]. 2011年5月在威尼斯附近的一个软件架构工作坊中,大家开始讨论“微服务”这个术语,因为这个词可以描述参会者们在架构领域进行探索时所见到的一种通用的架构风格。2012年5月,这群参会者决定将“微服务”作为描述这种架构风格的最贴切的名字。在2012年3月波兰的克拉科夫市举办的“33rd Degree”技术大会上,本文作者之一James在其“Microservices – Java, the Unix Way”演讲中以案例的形式谈到了这些微服务的观点,与此同时,Fred George也表达了同样的观点。Netflix公司的Adrian Cockcroft将这种方法描述为“细粒度的SOA”,并且作为先行者和本文下面所提到的众人已经着手在Web领域进行了实践——Joe Walnes, Dan North, Evan Botcher 和 Graham Tackley。

[2]. “单块”(monolith)这个术语已经被Unix社区使用一段时间了。它出现在The Art of Unix Programming一书中,来描述那些变得庞大的系统。

[3]. 许多面向对象的设计者,包括我们自己,都使用领域驱动设计中“service object”这个术语,来描述那种执行一段未被绑定到一个entity对象上的重要逻辑过程的对象。这不同于本文所讨论的”service”的概念。可悲的是,service这个术语同时具有这两个含义,我们必须忍受这样的多义词。

[4]. 我们认为一个应用系统是一个社会性的构建单元,来将一个代码库、功能组和资金体(body of funding)结合起来。

[5]. 原始论文参见梅尔文•康威的网站:http://www.melconway.com/Home/Committees_Paper.html

[6]. 在极度强调高效性(Scale)的情况下,一些组织经常会使用一些二进制的消息发送协议——例如protobuf。即使是这样,这些系统仍然会呈现出“智能端点和傻瓜管道”的特点——来在易读性(transparency)与高效性之间取得平衡。当然,大多数Web属性和绝大多数企业并不需要作出这样的权衡——获得易读性就已经是一个很大的胜利了。

[7]. 忍不住要提一下Jim Webber的说法:ESB表示Egregious Spaghetti Box(一盒极烂的意大利面条)。

[8]. Netflix让SOA与微服务之间的联系更加明确——直到最近这家公司还将他们的架构风格称为“细粒度的SOA”。

[9]. “YAGNI” 或者 “You Aren’t Going To Need It”(你不会需要它)是极限编程的一条原则和劝诫,指的是“除非到了需要的时候,否则不要添加新功能”。

[10]. 单块系统使用单一编程语言,这样讲有点言不由衷——为了在今天的Web上构建各种系统,可能要了解JavaScript、XHTML、CSS、服务器端的编程语言、SQL和一种ORM的方言。很难说只有一种单一编程语言,但是我们的意思你是懂得的。

[11]. Adrian Cockcroft在他2013年11月于Flowcon技术大会所做的一次精彩的演讲中,特别提到了“开发人员自服务”和“开发人员运行他们写的东西”(原文如此)。

[12]. 这里我们又有点言不由衷了。 很明显,在更复杂的网络拓扑里,部署更多的服务,会比部署一个单独的单块系统要更加困难。幸运的是,有一些模式能够减少其中的复杂性——但对于工具的投资还是必须的。

[13]. 事实上,Dan North将这种架构风格称作“可更换的组件架构”,而不是微服务。因为这看起来似乎是在谈微服务特性的一个子集,所以我们选择将其归类为微服务。

[14]. Kent Beck在《实现模式》(Implementation Patterns)一书中,将其作为他的一条设计原则而强调出来。

[15]. 当SOA这个词在本世纪初刚刚出现时,有人曾说:“我们很多年以来一直是这样做的。”有一派观点说,SOA这种风格,将企业级计算早期COBOL程序通过数据文件来进行通信的方式,视作自己的“根”。在另一个方向上,有人说“Erlang编程模型”与微服务是同一回事,只不过它被应用到一个企业应用的上下文中去了。


更多精彩洞见,请关注微信公众号:思特沃克

Share

浅谈微服务架构中的鉴权体系

在微服务架构中,有一个核心的问题是处理好“集权”(中心化)和“放权”(去中心化)的关系。虽然微服务的主旋律是把数据和业务拆成小而独立的模块,但我们仍然需要一个强力的中央安保体系来确保“数据分散,权限集中”。这一篇就谈谈微服务架构中的鉴权体系。

身份认证

身份认证(Authentication)的目的是证明“你是你(所号称的那个人)”。

要证明这一点,你必须掌握一个只有你自己和认证机构才知道的机密信息。在现实中,这个信息可能是 DNA、指纹、虹膜这样的生物识别特征,但由于这种特征跟人身直接绑定且又不可修改,一旦泄露,可能被持续冒用,造成不可挽回的严重后果,所以现实中较少采用这些生物识别特征作为识别之用。

如果不采用机密信息作为判断标准,就需要一个持续的、不易伪造的“证明材料”。在中国,这个证明材料就是户口或身份证。中国对公民信息的登记相对严格,所以会在小孩出生的时候要求把身份信息登记到户口之中,形成身份证明,跟随一生。在需要证明“你是你”的时候,拿出身份证就行了。

(生物特征不可废弃,所以我们必须把它包一层,形成证明材料和对应的 Persona)

与生物识别特征不同的是,身份证如果丢失,从理论上说,应该可以挂失并且让其失效,然后办理一张新的身份证。不过,设计我国身份证的机构和供应商也许没有考虑到这个问题,或者考虑到现实情况太复杂,导致身份证无法挂失,丢失的身份证仍然具备证明效力,但这个是后话了。

为了避免身份证被冒用,在对身份认证要求比较严格的场合(比如银行),会附加一些别的检查,比如对比照片等等。

那么,现在我们来对身份认证进行规划。

  • 身份认证机构可以颁发两个东西给用户,作为身份认证的输入:机密信息或证明材料。
  • 身份认证机构可以通过对比用户提交和机构保存的机密信息来判断用户身份。
  • 身份认证机构可以通过检查和对比用户提交的证明材料来判断用户身份。
  • 如果需要,身份认证机构可能会附加别的验证来增加认证可信度。
  • 用户可以变更机密信息,避免冒用。
  • 用户可以挂失证明材料,使证明材料失效,避免冒用。

身份认证中的机密信息在 Web 环境中通常以用户名和密码的形式存在。由于 HTTP 协议没有“状态”的概念,所以对于 Web 服务器来说,每次请求都是全新的体验,都必须验明请求者的正身。要做到这一点,客户端可以在每次请求的时候都附上用户名和密码(或者别的凭据),表明身份。

可是,每次都发送用户名和密码增加了泄露风险,所以在第一次验明正身(登录)之后,服务器可以发给调用者一个“令牌”(Token)。这样,后端的后续身份认证,无外乎就是把令牌换成“身份”(Identity)。这个令牌实际上就是前面说的证明材料。

我们应该尽量让令牌不容易仿造,但是技术上无法做到完全杜绝。所以,在敏感操作的时候可能会附加一些别的验证,比如再次输入密码或者用短信验证码做二次校验,这也就是前面所说的附加验证。

权限验证

和身份认证相比,权限验证(Authorization)要复杂一些。

身份认证的输入,要么是用户名和密码(或别的身份凭据),要么是令牌,只需要通过一个检查,就能输出身份信息。而权限的验证要检查的是“某用户能不能做某事情”,所以,至少需要有两个输入:“用户身份”和“想要执行的动作”。除了这两个输入之外,还需要有一个具体的“判断规则”,验证者才能根据规则,输出“同意”或者“拒绝”。

在现实中,这个判断规则有很多种可能。

  • 在等级森严的军队里面,所有的动作和文档都有明确的“查阅级别”,而每个人也有自己的“查阅级别”。只有用户的级别高于动作的级别,才能执行这个动作。
  • 在分工明确的工厂里面,每个人都只负责自己的工作,那么,所有的动作和资源都按照不同的工种来进行分配。各工种只能执行属于自己负责范围的动作,获取属于自己负责范围的资源。
  • 在架构明确的公司里面,每个人都属于公司行政架构中的一个节点,可以执行属于这个节点的动作,并且访问这个节点及其下属节点的信息。
  • 在专家主导的医院里面,所有人都围绕专家的需要服务,而专家则为病人服务(执业)。根据专家的需要,同时保护敏感信息,我们可能会设置更加复杂的判断规则,比如根据时间段、服务流程阶段等来判断,或者提供一个特定的委托授权的流程用于临时开放权限。

不管怎么变,只要有了身份、动作和规则,我们就能做判断。当然,如果规则要求我们核实部分数据,我们还需要这部分的数据作输入。不过,由谁来执行这个判断比较合适,值得我们探讨一下。

举一个生活中的例子。

有这么一家公司,在 A 市有个办公室,办公室有个戴经理。戴经理有一天兴之所至,想起来要查一下员工老王的工资。他来到了 HR 部门,找到了 HR 主管,想要调老王的工资出来看看。

HR 看了看公司规定,经理只能看自己所辖办公室的员工工资,然后又看了看戴经理,是负责成都的,再看看老王,是成都员工,然后,就把老王的工资调出来给了戴经理。戴经理看了,然后说,再给我看看老陈的工资啊。然后 HR 调出档案一看,老陈是北京办公室的,就拒绝了。

又有一天,员工老王也兴之所至,想要查一下戴经理的工资。他也来到 HR 部门,找到 HR 主管,问戴经理的工资。HR 一看,你这不是经理啊,怎么能查别人工资呢,就直接拒绝了。

如果看这个例子,我们就会发现,这个规则的检查是 HR 做的。实际上,绝大部分非 IT 的业务流程中,权限的检查都由信息的保管方来执行。

我们当然也可以按照这个来建模,但是稍等,再深入分析一下。

  • 首先,“谁能看谁的工资”这个规则,是不是 HR 部门来决定的呢?不是。公司的规章制度决定了“谁能看谁的工资”,规章制度由公司管理者制定。
  • 然后,当公司制度需要调整的时候,是不是由 HR 部门来调整呢?不是。 还是由管理者来制定,然后由各个部门来执行。各个部门实际上是收到了制度调整的结果,而不是自己去调整制度。
  • 最后,“谁能看谁的工资”这个规则,是不是 HR 的专业范围?不是。只有“调出工资档案”这个动作是 HR 的专业范围,至于“谁能看”,其实跟 HR 的专业知识没有直接关系。

要理解最后这一点,我们可以看两个场景。

  • 某 HR 换了一家新公司。现在这个新的公司很有意思,允许所有人看所有人的工资,层级也不同,规章完全不同,但 HR 仍然可以按照自己的专业来工作不受影响。不只是 HR,对其他部门的人来说也是如此。规章制度的变化,对它们的职责没有实质的影响。
  • 某 HR 换了一家新公司。这家公司专门搞高精尖的研究,对于员工的信息和商业机密控制极其严格。只要有人来调取数据,都必须经过专门的审核人员审核放行。这对 HR 的职责也没有实质影响,只要能通过审核,照办即可。审核人员也不知道 HR 具体的工作是什么,只知道规则要求检查什么,就去检查什么。

总结就是以下几点。

  • 专业知识(领域逻辑、业务规则)和权限是相对独立的东西。
  • 要运用专业知识,不需要知道权限。
  • 要检查权限,不需要用到专业知识。

既然如此,为什么在现实中还是由专业人士来兼职检查权限呢?这也许是因为对于很多公司来说,绝大部分的数据都没有那么敏感,所以为了降低管理成本,绝大部分的数据访问都没有那么严格地用专职人员去检查,而是由专业人士代劳。

了解了这些之后,我们就可以开始规划了。

  • 权力机构会制定一套权限规则,并且可能调整这套规则。
  • 这套规则可能会用到一些外部的输入,比如员工所在的办公室。
  • 有了这套规则和查验数据的权力,任何人都可以判断一个动作是否合规。

这样做的好处,就是业务变得非常纯粹,而权限相关的东西完全挪出业务层面,即便业务或者权限需要频繁变化,问题也不大。

说到这里,也顺便抛一个待验证的设想:不同公司的业务逻辑总是高度雷同,差别最大(妨碍复用)的其实是公司的管理体系。我们把组织结构和与之相关的安全权限单独拎出来,也许可以更好地促进业务逻辑的复用。

鉴权服务

为了提升服务的效率,我们一般会希望尽早地做完身份认证和权限验证。如果用户执行了越权操作,那我们应该及早中止访问并返回错误提示。

前面提到,权限验证的输入之一是用户身份,所以身份认证和权限验证通常是前后脚来做。二者组合,形成鉴权服务(Auth Service)。鉴权服务负责维护令牌身份映射以及权限规则,它的输入是“令牌”和“想执行的动作”,输出是“身份”和“是否允许执行”。

几个例子

现在,我们用这样一个场景来实验一下整个鉴权流程。

设有这么一个订单管理系统,其中有一个订单查询功能。其权限要求如下。

  • 买家只能看到自己下的订单
  • 卖家只能看到下给自己的订单
  • 卖家管辖多个小二,小二可以分组给不同的权限,有的只能看分配给自己的订单,有的可以查看分配到自己组的订单
  • 运营商可以看到所有订单

要对这个权限体系进行建模,我们必须认识到,这些操作,虽然查看的都是订单,但是因为是不同的业务上下文,表现到 API 呈现上也会有不同。

  • 买家看自己的订单:/CustomerViewOrders
  • 卖家看自己的订单:/MerchantViewOrders
  • 运营商看任意订单:/AdminViewOrders

然后,我们可以制订如下规则。

  • 所有这些 API 都要求用户处于已登录状态
  • 对于 /CustomerViewOrders ,访问者必须有 customer 身份
  • 对于 /MerchantViewOrders ,访问者必须有 merchant 身份
  • 对于 /AdminViewOrders,要求当前用户必须有 admin 身份

这样,鉴权服务就可以根据身份、动作和规则三者来判断访问权限了。

(图片来自:http://t.cn/RHRujGG)

至于卖家给小二的权限分配,根据不同需要,我们可以选择两个方案。第一是让卖家自己去处理这个细粒度的权限,形成自己的一套小的权限体系,这也意味着小二访问的可能是因卖家中转而暴露出来的新 API。第二是把这个细粒度的权限也建模到原来的权限体系里面,加入如下新的 API 和判断规则。

  • 小二查看订单: /ClerkViewOrders,检查:
    • 用户必须是 clerk 身份
    • 用户在组织结构上必须属于某个 merchant
    • 如果用户类别是 1,那么他可以查看所有分配到自己组内任意小二的订单
    • 如果用户类别是 2,那么他可以查看分配给自己的订单

我们再来看另一个场景,查看员工信息。API 的和规则的设计如下。

  • 所有 API 要求用户处于登录状态
  • 员工查看自己的信息:/EmployeeViewOwnProfile
    • 所有员工均可访问
  • 员工查看其他员工的信息:/EmployeeViewProfile
    • 所有员工均可访问
  • 经理查看员工信息:/ManagerViewProfile
    • 当前用户必须为 manager 角色
    • 请求中的员工必须属于该 manager 负责的 location

不同的 API 返回的数据可能有差别,比如看自己的信息可以看全,看别人的只能看名字、照片和联系方式,经理则可以看所有人的完整信息,这由应用逻辑决定。

(图片来自:http://t.cn/RHRu1e3)

再来看一个医院的。医院有一点不同的是,病人和病历实际上需要在多个部门之间周转,而不同的角色处在不同部门的时候,其职能和权限会有变化。比如, 有时候实习医生会守急诊室,住院医生不在的时候护士也需要代理执行医嘱,职工可能会轮岗到不同部门,等等。

基于这样一些假想场景,我们可能会有如下一些 API 和权限。

  • 挂号处,要求用户必须有 clerk 身份
    • 建档:/RegistrationsCreateMedicalRecord
    • 挂号:/RegistrationsCreateVisit
    • 查看病历(用于确认病人已建档):/RegistrationViewMedicalRecord
  • 门诊部,要求用户必须有 doctor 身份
    • 诊断:/OutPatientCreateDiagnosis
    • 开药:/OutPatientCreatePrescription
    • 查看病历:/OutPatientViewMedicalRecord
  • 急诊室,要求用户必须有 doctor 身份
    • 查看病历:/EmergencyViewMedicalRecord
  • 住院部,要求用户必须有 doctor 或者 nurse 身份
    • 入院:/InPatientAdmitPatient
      • 仅 nurse 可以执行入院
    • 日常检查记录:/InPatientCreateRoutineRecord
      • doctor 只能给自己分管的病人创建检查记录
      • nurse 只能给自己负责区域的病人创建检查记录
    • 创建医嘱:/InPatientCreateOrder
      • doctor 只能给自己分管的病人创建医嘱
      • nurse 不能创建医嘱
    • 出院:/InPatientDismissPatient
      • 仅 nurse 可以执行出院
    • 查看病历:/InPatientViewMedicalRecord
      • doctor 只能查看自己分管病人的病历
  • 手术室,要求用户必须有 doctor-surgeon 身份
    • 准备材料:/OpRoomPrepareMaterial
    • 记录结果:/OpRoomCreateOpRecord
  • 检查部,要求用户必须有 technician 身份
    • 录入结果:/LabsCreateExaminationRecord
    • 查看病历:/LabsViewMedicalRecord
  • 药房,要求用户必须有 pharmacist 身份
    • 看处方:/PharmacyViewPrescription
    • 放药:/PharmacyDeliverMedicine

上述 API 能访问到的数据和权限主要根据部门来进行划分,方便轮岗。比如,医生在门诊的时候,可以查看完整的病人病历,但轮岗到挂号处的时候,虽然也查看病历,但就只能查看最基本的个人信息了,用于给病人补办卡片之类。

功能和数据权限

从上面几个例子看来,我们通常可以把权限的验证分成两个步骤:先确定职能,然后确定职能作用范围。

比如,先确定你能看订单,然后确定你能看哪些订单;先确定你能看工资,然后确定你能看谁的工资。再比如,某国法律规定,当一个案件发生在某地,警察来调查,但只有该辖区的警察有调查权,跨区域的案件必须交给联邦警察。如此等等。

既然这两步看上去分得很清楚,那么我们不妨给它们分别取名。用户能不能执行某个动作,使用某个功能,是功能权限,而能不能在某个数据上执行该功能(访问某部分数据),是数据权限。

促成这种拆分方式的原因可能有下面几种。

  • 现实中,很多组织采取了这种“职能 + 组织节点”的形式来确定权限,所以这样的拆分实际上为建模提供了方便。
  • 由于功能权限通常会直接对应应用的 API 列表,所以权限验证可以及早失败,而无需把数据取出来做对比,提升了鉴权的效率。
  • 方便我们把所有的功能 API 提取出来形成一个列表或者表格,可以更好地查看和管理权限。

此外,这种形式的权限管理还可以让业务人员在不写代码的情况下对功能权限进行重新分配。如果涉及数据权限,则必然会有某种形式的判断逻辑,写代码也就必不可少了。

话说回来,尽管这种拆分很常见,我们仍应该认识到这只是人为的一种拆分。二者都是权限验证的一部分,都是为了回答“该用户能不能做某件事”这个问题,本质没变。

需要注意的是,在制定权限规则时,制订者需要参考业务规则,但是反之则不然。业务规则可以在完全不了解权限验证规则的情况下执行。甚至,从理论上说,所有的业务单元都应该可以在完全没有权限验证的情况下“正常裸奔”,即假设所有人可以做所有事情,但业务应该被正常执行,业务规则应该被正常遵守。用语言学的词汇来说,就是在没有权限验证的情况下,业务数据中也许会有语义问题(semantic problem),但是不会有句法错误(syntax error)。

鉴权体系回顾

我们来回顾一下这篇文章中提到的鉴权体系。

  • 身份认证。确认“你是你”,获取你的身份信息。
  • 权限验证。确认“你能做某件事”。

二者合称为“鉴权”。身份认证输入令牌,输出身份。权限验证输入身份、动作(包括动作范围),输出“同意”或“拒绝”。我们希望身份和权限在一个体系内高度一致,所以,鉴权是一个半中心化的行为,权限规则在一个体系(比如组织、应用)内是中心化管理的。

权限的形成需要对业务知识的了解,但规则抽象出来之后,要使用它就不需要业务知识了。权限验证的独立,意味着我们把“权限规则”和“业务规则”拆成了两个部分。前者拥抱变化,而后者追求稳定;前者在意的是业务的意义,后者在意的是业务的逻辑。

为了适应现有组织形态和更清晰地展示权限信息,在给权限建模的时候我们常常会把它拆分成功能和数据权限两种。我们应该认识到二者都是权限验证的一部分,都是为了回答同一个问题:这个用户能不能做某事。

从整个分析脉络我们可以看到,这个鉴权体系是通用的。在设计任意一个系统的过程中,我们都应该注意尽量把安全相关的判断和业务规则拆开对待,方便集中管理权限,把业务规则提纯。

对于微服务架构来说,鉴权是一个重要的节点,它和应用场景密切结合,是安保的最后一道关口。在对权限进行建模的时候,我们应该尤其谨慎。希望这篇文章能给大家一些启示。


更多精彩洞见,请关注微信公众号:思特沃克

Share

浅谈微服务基建的逻辑

这篇文章主要目的是面向初接触微服务的朋友简单介绍微服务基础建设所需要的各个模块以及缘由。

起点

首先,我们得有一个“服务”。根据定义,我们可以把每个服务实例都视作一个黑盒。这个盒子有着明确的输入点和输出点,并且(理想情况下)仅通过这些输入和输出点和外界产生关联。每个服务实例会拥有专属的网络地址、独立的计算资源,并且独立部署。客户端通过访问服务实例的地址来调用服务 API。不同服务也可以相互调用。

配置管理器:统一管理配置

在微服务体系中,每个服务都独立部署和运行,团队可以根据需要自行选择增加和减少计算资源。一个服务可能会跑多个实例,每个服务实例都会需要做配置。为了方便统一调整配置,我们可以把配置中心化,每个服务实例都去找配置管理器(Configuration Manager)拿配置。当配置更新的时候,我们也可以让服务实例再去拿新的配置。

服务名册:解耦主机地址

这也引出了一个问题:网络地址(比如 IP)很容易因为扩容、维护而变动,调用者难以实时获知可用的地址。

鉴于此,我们可以把网络地址抽象成不容易变动的概念,比如给每个服务一个固定的名字。互联网使用 DNS 来解决这个问题,对应到微服务基建里面就是服务名册(Service Registry)。

每个服务实例在运行期间,都会以心跳的形式向服务名册发送注册信息,包括服务的 ID 、访问地址以及健康状况。这样,需要访问服务的时候,客户端就可以先问服务名册拿可用的实例地址,然后再访问实例来调用服务。除了更好地定位实例地址,服务名册还可以在某些实例下线、维护或升级的时候把其临时从名册中去掉,让服务不断线。

服务之间的调用也是如此,先找名册拿网络地址,再进行调用。

API 网关:入口和路由

找名册要地址,然后调用服务 API,这些是每个客户端都会去做的琐事,我们完全可以把这些事情抽象、集中,把服务的 API 整合到一个大的中心点,然后把要地址和调用服务 API 这样的细节封装起来,所有客户端都只跟这个中心点对话,不再直接访问单个服务。

从结构上看,这个中心点把整个架构划分成了内外两部分,内部是所有的服务,客户端则在外部,中心点站在中间。它作为内外的唯一通道,被顺理成章地命名作“API 网关”(API Gateway),有时候也被称做“边缘服务”(Edge Service)。

API 网关作为唯一出入口,又占据了最前沿的有利位置,所以有时还会承载别的公共功能,比如我们马上会提到的鉴权。

鉴权服务:身份和权限问题

顺着这个架构继续开发,我们会遇到新的问题:不方便的鉴权。

鉴权(Auth)包括了两个部分:身份认证(Authentication)和权限验证(Authorization)。身份认证关心的是“你是谁”,权限验证关心的是“你能不能做某件事”。

身份和权限都是高度中心化的概念。

对于一个系统来说,用户的身份必须是统一的。不能说这个用户在做这个事情的时候是张三,做那个事情的时候是李四。此外,用户的认证状态也应该是统一的。不能说用户访问这个服务的时候是已登录认证,访问另一个服务时又是未登录状态。所以,只能有一个身份认证方。

权限稍微复杂一点。和身份不同,权限通常分成两种类别:功能权限和数据权限。这样的划分对应了现实世界中常见的权限模式:你的角色决定了你的职能,而职能范围通常由附加条件来限制。比如,你是一个法官,对案件有裁决权,但是你是 A 区的法官,只能判 A 区的案子。再比如,某个快餐门店的经理有权看员工的详细资料,但是只能看自己门店的员工资料。

两种权限都由全局的规则来确定,而不掌握在执行部门。比如,谁来判案,取决于法律,而不取决于法院。谁能查看谁的资料,也不由资料保管部门决定,而由规章制度决定。

在现实的情况中,组织可能会有专门的审核部门来验证权限,但对那些不是特别敏感的权限,企业会让各个部门自行验证。不过不管谁来执行验证,都必须拿着同一份规章制度,不能各说各话。这份制度必须由中心机构来统一制定、维护。也就是说,权限的管理也应该中心化。

明确鉴权中心化之后,我们就可以开发一个公用的鉴权服务,执行身份认证和权限验证。下一个问题是:谁来发起鉴权?

所有服务的调用都要求调用者明确自己的身份,所以自然身份认证越靠前越好。作为出入口的 API 网关自然是发起身份认证的不二之选。权限验证则稍微复杂,完全值得另起一文详述。此处我们暂时假定权限验证也由 API 网关来发起。

消息中介:异步和通知

开发继续进行,一切风平浪静,技术上暂时没有什么问题。不过,业务上有一个问题需要解决。

比如,我们做一个在线商城,要求在订单成功创建的一刻,仓库就要启动备货和发货的流程。问题是,订单和仓储是两个服务,不同团队在负责,而且从关注点来说,订单服务并不关心仓储相关的问题,所以订单服务不可能在创建订单的时候去主动通知仓储服务。仓储服务只能定时轮询订单服务,看看有没有新的订单。这不仅麻烦,而且实时性不够。

仔细想想,我们会发现这种需求很常见,信息的产生者并不知道(也不关心)谁会对信息产生兴趣。比如我们可能会有一个监控服务需要实时展示产品销量,有一个 BI 服务需要获取客户购买产品的信息来做分析,等等。既然这是一个常见需求,我们不妨把它模式化,形成一个机制:信息产生者把通知发出来,收到通知的人再确定是否需要采取行动。

这就意味着我们需要再引入一个中心化的公共服务:消息中介(Message Broker)。当某个事件发生的时候(比如用户激活成功、订单创建成功),服务可以朝消息队列发一条消息。而其他服务可以订阅这些消息,并针对这些消息做出反应。

比如,仓储服务可以订阅订单创建成功的消息。这样,订单成功创建后,订单服务将这个消息发到消息中介,消息中介通知仓储服务,仓储服务一看,就问订单服务要新的订单信息,最后,启动出库流程。

消息中介除了能广播事件之外,还能做异步调用。把同步的调用转化成异步的回调。针对调用时间长和不要求实时结果的调用,可以增加性能,提升体验。

前置后端:优化前端开发

走到这里,其实体系已经比较完备。现在的问题是,如何让微服务基建结构和研发团队常见的结构更好地对应起来。这要求我们从康威定律的角度来看待整个基建的设计。

在围绕用户和价值的软件研发流程中,我们常用用户历程和用户故事来捕捉和跟踪价值的实现。一个用户故事通常会包含一个有明确边界、明确验收标准和明确价值的业务步骤。

问题在于,支撑一个故事有前后两端的研发工作,二者是不同步的。前端由业务流程和设计来驱动,希望按顺序产出;后端则由业务资源和建模来驱动,希望按模块来产出。

比如说,前端常常会因为设计的原因调整自己需要的字段,而后端从建模的角度并没有这个需要,也没有动力频繁地去跟随前端的调整,使得前端不得不在不稳定的网络条件下传输多余的信息,占用了宝贵的网络带宽。

此外,前端呈现某个业务步骤的时候,有两种信息不属于当前必备信息,但常常需要和必要信息一起展示。一种是状态信息,比如当前的登录状态和用户名,短消息的数量等等。一种是垂直相关的信息,比如在展示文章的时候顺便展示一下相关的文章。

这就要求前端在调用主服务的同时还要再调用多个不同的服务。且不说这些服务有可能会有调用超时、出错的可能,仅仅是多出来一堆异步请求,就已经足够让前端效率降低一大截了。

在微服务体系下,这些问题更加严重,因为现在不仅仅是前后端的差别,不同服务还由不同团队负责。这些团队的诉求和日程不一,很难做到前端所需要的快速响应。

这些问题和麻烦可能会催生一个“缓冲带”,比如后端出专人来负责对接前端的需要,或者前端派驻一个人到后端来谈需求。按康威定律,这种沟通体系,久而久之,很容易以软件的形式沉淀下来,形成一个专属的中间层。

要调和前后端的不同步是不可能的,而这种中间层是自然催生的解决方案,可以保留。新的问题是,它的职责是什么?应该把它放在哪?应该由谁来维护?

分析下来,其责任有二。第一是解耦前后端的工作,降低相互的影响。前端需要的东西可以写在中间层里,让它频繁变化也没有关系。后端如果还没有准备好,前端也可以在这一层模拟假的数据,不至于被阻塞。第二则是提升前端的运行效率。前端可以把所需要的多个服务的东西统一汇总,一次拿完,免得发多个请求。

放置的位置则在 API 网关之内,让它可以享有 API 网关所带来的好处和保护。

最后是维护问题。按照“谁主张,谁举证”的原则,既然有了这个中间层,好处让前端得了,那么,理论上应该由前端来维护。

这样,一个主要为前端服务的中间层就定义好了。不同类型的前端(桌面、移动)可能会有不同的需要,为了避免中间层的碎片化,我们可以让各个中间层都特定的前端类型紧密耦合,比如桌面专用、移动专用。如此,每个中间层都像是某类型前端的专享后端,所以“前置后端”(Backend-for-Frontend,简称 BFF)也因此得名。

回路熔断器:提高容错度

现在,调试也方便了,我们又继续开发。一开始没有什么问题,但部署到预生产环境的时候,又一个问题出现了:整个体系的容错度很低。一个小错误容易被层层传递和放大,导致整个体系的崩溃。

我们都知道,编程最麻烦的就是远程调用。本地调用大部分时候结果是“成功”或“失败”,但远程调用则很可能是“无响应”。“无响应”有可能是正常的,对方可能稍后会给你结果,也可能是因为对方已经死了,没法给你响应。最坏的结果,就是门口挤满了人,大家都在等你给结果,而你也在等别人给结果,资源全部占用来等,什么也做不了。

不过,远程调用是无法避免的。在微服务体系中,这个问题被进一步放大。这是因为微服务的模块化以服务为单位,而每个服务独立部署和运维,使得服务之间的调用成了家常便饭。

在这种严峻的情况下,我们必须从架构上尽量提高整个服务体系的容错度,让个别服务的问题不至于影响到全局。

具体的做法,则是给远程调用加一个熔断阈值检查,当调用超时次数超过阈值时,就不再调用,直接返回错误。过一段时间之后,再把阈值恢复,尝试继续调用,重复前面的过程。这个机制就是回路熔断,而这个工具则是回路熔断器(Circuit Breaker)。

除了隔离已经出错的服务实例,熔断器还有一个重要的功能是提供备用方案。虽然我们把所有业务都拆成了服务,但服务有高低贵贱之分。有一些服务属于关键服务,一旦出问题,则整个流程无法继续,有一些则属于分支服务,即便错了,也不会影响大局。

比如说,购买商品的时候,常常会根据用户的习惯和当前正在购买的东西做一些推荐。负责推荐的服务出问题的话,大不了就不推荐了,不应该影响用户正常的购买流程。同理,如果是在线点餐的地址定位服务出问题了,我们也应该允许用户手动选择餐厅进行点餐——体验虽然不佳,但至少正常的流程仍然可以走完。基于这个考虑,熔断器应该为非必要的服务调用提供备用方案,尽量保证核心流程的顺畅。

有了回路熔断器,远程调用出错的问题就从一定程度上缓解了。结合回路熔断器和对熔断阈值变化的监控,开发者可以更容易地发现问题,并及时采取行动。

负载均衡器:提升服务弹性

要正式上线,我们还必须做好负载均衡(Load Balancing,下简称 LB),提升整个服务的弹性。要做负载均衡,从理论上有两种方式:

客户端负载均衡(Client-Side LB):由客户端来决定如何分散请求。 中间方负载均衡(Mid-Tier LB):由 DNS、网关等中间方来决定如何分散请求。

现在,服务名册中已经有了服务及其对应的实例地址列表,所以客户端的负载均衡最简便的方式就是把地址拉下来,然后依次或者随机选择可用的地址。中间方的负载均衡则选择面较多,从最外层的 DNS 到网关都可以不同程度地去按需要去做。

扩展基建

现在,微服务基建基本完成了。如果有需要,我们可以对这个基建进行扩展。在做扩展时,架构师应该注意区分哪些东西应该中心化,哪些东西应该由服务自行决定。 比如说,在本文提到的基建之中,(几乎是)强制完全中心化的模块有:

  • 配置管理
  • 服务名册
  • 消息队列

其中,配置管理和服务名册是所有服务都需要的基础设施,必然需要统一。消息队列和日志收集都是为了跨服务的操作和追踪,也必须中心化。

半中心化的模块则有:

  • 路由
  • 鉴权

路由和鉴权都必须统一,我们前面讨论过。不过,微服务可能会向外界暴露“自用”和“客用”等多套公共 API(比如快递公司内部使用的物流 API 和开放给第三方使用的物流 API),所以可能会有两个 API 网关,对应会有两套 API 目录和两套鉴权体系,所以,它们是“半中心化”。

这些都是中心化、半中心化的选择范例。每一次中心化的选择都可能会让整个架构变得死板,失去灵活性,所以,我们在设计和扩展基建的时候应该特别注意这个问题。

除了中心化的选择之外,架构发展的另一个关注点,是让业务保持“黑盒”。

我们把每个服务之间的关联抽取了出来,也把权限的定义和验证抽取了出来,每个服务变得简单而纯粹,成了“纯业务式服务”,等同于一个仅包含了业务规则的黑盒。这样,不管服务和模块再多,也没有影响。业务的重用性也很高。

总而言之,搭建好了微服务的必要设施之后,剩下的就要根据实际情况和项目经验来继续调整了。比如,我们可能会选择把很多功能合并到一层,以避免过度分层所带来的不必要的性能损失,或者对整个基建进行一些细节微调。只要把控好“中心-自理”和“业务-非业务”之间的关系,这个基础设施就能健康地发展。

微服务基建总结

总结此文,微服务的基建应该包括如下一些组件(按请求流中的出场顺序):

  • 配置管理:配置集中管理。
  • API 网关:对外的 API 总目录;API 依赖关系;发起鉴权。
  • 服务名册:服务的注册和发现。
  • 鉴权服务:提供鉴权服务:认证身份,验证功能权限。
  • 前置后端:按前端的需求拆解请求、调用服务,并汇总、转换结果。
  • 消息中介:全局通知机制;异步调用机制。
  • 回路熔断:隔离出问题的服务并等待其恢复;提供备用方案。
  • 负载均衡:避免服务过载。

需要说明的是,这些组件的组合形式,具体拆分形式,是否需要,都需要结合实际项目和团队的情况来调整。本文权作抛砖引玉,请读者知悉。

Share