Skip to content

Latest commit

 

History

History
267 lines (194 loc) · 12.7 KB

03.从单块软件到微服务.md

File metadata and controls

267 lines (194 loc) · 12.7 KB

03.从单块软件到微服务

大多数公司都会经历如下发展模式:

  • 一个公司由几个精通专项领域知识的人才组成,例如:保险、支付、信用卡等领域。
  • 随着公司的成长,不断出现新的需要快速响应的业务需求(例如,规范管理制度、解答大量小白客户的问题等),公司会因此进入一个自然增长阶段。
  • 接着,公司将会经历下一个增长阶段,在这个阶段,业务交易定义明晰,此时难以维护的单块软件已不能很好地完成对业务的建模。
  • 由于最初软件构建方式的限制,导致公司不断增加人力来解决该问题,伴随而来的是增长的痛苦与低下的效率。

本章不仅介绍如何避免上述情况的出现(失控的自然增长),还将介绍如何使用微服务来构建新系统。

首先,我们拥有一个单块软件

有很大比重的(我估计在 90%左右)现代企业软件是按照单块软件的方式构建的。

运行于单一容器且开发周期严格定义的大型软件组件是完全违背敏捷开发原则的。敏捷开发的原则是:及早交付和频繁交付(https://en.wikipedia.org/wiki/Release_early,_release_often),如下所述:

  • 及早交付: 你失败得越早,就越容易修复。如果你的软件开发周期持续了两年后才发布,将会有极大的风险与原始需求产生偏差,因为在这个过程中,需求可能会由于不合理而常常发生变更。
  • 频繁交付: 频繁交付可以使软件干系人(stakeholders)了解开发进程,并且及时了解项目中的变更。从而能在几天内就修复错误,并且轻松发现系统优化点。

许多公司之所以使用大型软件组件而不是小块服务的结合,是因为这样做相当自然,其过程如下所示:

  • 开发者接到一个新需求。
  • 他在服务层已有的类中添加了一个新方法。
  • 写好的方法会暴露成 API,可以通过 HTTP、SOAP 或者其他协议访问该 API。

将这个过程乘以公司的开发者数量,得到的产物便叫作自然增长。自然增长指的是,由于缺乏充分的长期规划,在业务压力下,软件系统无计划、无控制地增长。这样的情况相当糟糕。

如何控制自然增长

控制自然增长的第一要素就是让 IT 与业务干系人协同工作:培养非技术干系人成为成功的关键。

可以将工作内容划分成若干个可控的软件工件,并为每个工件对应一个定义好的业务活动,并为它分配一个实体(entity)。

在这一阶段,还不需要将其暴露成微服务,但是,将逻辑保持在一个独立的、定义良好的、易于测试且解耦的模块中,将对我们未来改造应用带来极大的好处。

创建具有良好边界和具有单一职能的细粒度服务,其中单一职能表示将事情做得小而精。

多抽象才是过度抽象

在构建软件时,我有一条始终努力秉持的黄金法则(“努力”一词是非常恰当的,因为这常常会遭到极大的反对),那便是避免过早地抽象。

回忆一下,你曾经多少次被一些简单的需求逼到了角落:假设需求是为解决 X 问题而编写一个程序,然而,你的团队成员已经对该问题进行了处理,并提前考虑了 X 的所有变体情况,做出了解决方案,但是并没有考虑这样做是否合理。接着,软件投入生产使用,某位干系人提出了一个你们从未考虑到的 X 的变体情况(例如之前的需求甚至是不正确的)。而现在,如果想要让这一个变体情况可解,得耗费你许多天的时间来进行大量的重构工作。

避免这个问题的方式很简单:在没有重复使用 3 次以上的场景下避免使用抽象

记住,在构建微服务的时候,应该把每个微服务构建得足够小,这样可以在单个 sprint(大约两周时间)内进行重构。在如此短的周期内完成软件原型的好处在于,一旦有更明确的需求时,对原型进行重构是值得的,因为让相关干系人直接看到产品是最快敲定需求的方式。

Seneca 十分符合微服务的设计哲学,通过模式匹配,能够在对已有代码无影响的情况下扩展微服务已有的 API:我们的服务对扩展开放,对修改关闭(SOLID 原则之一),增加功能的同时不影响已有功能。

微服务的出现

module.exports = function(options) {
  var init = {};
  /**
   * 发送短信
   */
  init.sendSMS = function(destination, content) {
    // 发送短信代码实现
  };

  /**
   * 读取未读短信列表
   */
  init.readPendingSMS = function() {
    // 接收短信代码实现
    return listOfSms;
  };

  /**
   * 发送邮件
   */
  init.sendEmail = function(subject, content) {
    // 发送邮件代码实现
  };

  /**
   * 读取未读邮件列表
   */
  init.readPendingEmails = function() {
    // 读取未读邮件代码实现
    return listOfEmails;
  };
  /**
   * 这段代码将已读邮件打上标记,这样它们就不会被
   * readPendingEmails函数重复读取。
   */
  init.markEmailAsRead = function(messageId) {
    // 将信息标记为已读代码实现
  };

  /**
   * 这个函数将待印刷及邮寄的文件放入队列中
   */
  init.queuePost = function(document) {
    // 邮寄队列代码的实现
  };

  return init;
};

该模块可以被简单地称为通信服务,并且用户很容易猜到它具有哪些功能。它管理着邮件、短信以及邮寄等通信方式。

通信渠道或许已经足够多了。如果开发者继续在这个服务中加入其他通信渠道的相关方法,那么该服务的增长就进入了失控阶段。这就是单块软件的关键问题所在:限界上下文跨越了多个领域,从而影响到软件的功能性和可维护性这两方面的质量。

如果我们要加入其他通信渠道,例如 Twitter 和 Facebook 的通知,那又会发生什么?

服务会进入失控增长阶段。你将拥有一个难以重构、测试和修改的大型模块,而不是多个小型功能性组件。让我们回顾一下第 2 章中提到的 SOLID 设计原则。

  • 单一职责原则: 单个模块处理了太多任务。
  • 开放封闭原则(对扩展开放,对修改关闭): 单块模块在增加新功能时需要修改模块代码,这可能会改变公用代码。
  • 里氏替换原则: 这里我们仍然跳过该原则。
  • 接口分离原则: 单块模块中并没有定义接口,只有一系列功能的堆砌实现。
  • 依赖注入原则: 并没有使用依赖注入。模块需要显式通过调用代码来构建。

如果不进行测试的话,会出现很多难以预料的结果。因此,我们通过 Seneca 将它切分成数个小模块。首先,邮件模块(email.js)如下所示:

module.exports = function (options) {
  /**
   * 发送邮件
   */
  this.add({channel: 'email', action: 'send'}, function(msg, respond) {
    // 发送邮件代码实现
    respond(null, {...});
  });

  /**
   * 获取未读邮件列表
   */
  this.add({channel: 'email', action: 'pending'}, function(msg, respond) {
    // 读取未读邮件代码实现
    respond(null, {...});
  });

  /**
   * 将信息标记为已读
   */
  this.add({channel: 'email', action: 'read'}, function(msg, respond) {
    // 标记信息为已读代码实现
    respond(null, {...});
  });
}

短信模块(sms.js)

module.exports = function (options) {
  /**
   * 发送短信
   */
  this.add({channel: 'sms', action: 'send'}, function(msg, respond) {
    // 发送短信代码实现
    respond(null, {...});
  });

  /**
   * 获取未读短信
   */
  this.add({channel: 'sms', action: 'pending'}, function(msg, respond) {
    // 读取未读短信代码实现
    respond(null, {...});
  });
}

邮寄模块(post.js)

module.exports = function (options) {
  /**
   * 将待打印、邮寄的信息放入队列
   */
  this.add({channel: 'post', action: 'queue'}, function(msg, respond) {
    // 邮寄信息放入队列代码实现
    respond(null, {...});
  });
}

现在,我们有了三个模块。这三个模块各自处理指定业务,并且不会干涉其他模块。因此,我们构建了三个高内聚的模块。使用以下方式运行上述代码:

var seneca = require('seneca')()
  .use('email')
  .use('sms')
  .use('post');

seneca.listen({ port: 1932, host: '10.0.0.7' });

我们并没有指定任何配置文件,只要通过模块的名字来指定使用它,Seneca将完成余下的工作。

启动Seneca,确认它是否加载了我们定义的这三个插件:

node index.js --seneca.log.all | grep DEFINE

在这个例子中,我以插件的形式来编写代码,这样得到的代码可以在不同机器上的不同Seneca实例中运行。这得益于Seneca提供了透明的传输机制,允许我们可以像对待微服务一样对单块应用中的各个部分进行快速重新部署和扩展,如下所述:

  • 新版本易于测试,因为对于邮件功能的变更只会影响发送邮件的功能。
  • 新版本易于伸缩。我们将在下一章中看到,复制一个服务和“配置一个新服务器并将我们的Seneca客户端指向它”一样简单。
  • 同样的,新版本也易于维护,因为单功能软件相当容易理解和修改。

微服务的缺陷

  • 自动化处理 - 人工操作的开销会使微服务带来的好处大打折扣。当你在设计一个系统的时候,应该总是考虑一个问题:如何自动化处理。自动化是解决这一问题的关键。
  • 应用的不一致性 - 一个团队的理想实践方式被另一个团队弃如敝履(尤其是异常处理上)。这样,会在不同团队间产生无形的隔阂,不利于工程师之间的交流。
  • 引入了更多的通信复杂性 - 可能会导致安全问题。相比原来我们只需控制单个应用服务及其与外界的通信,现在面临着多个需要彼此通信的服务器。

分割单块软件

考虑以下例子,贵公司的市场部想组织一次激进的邮件宣传活动,因此带来的邮件处理峰值将影响到日常的邮件发送功能。在高访问压力下,邮件发送将会出现延迟,这将会给我们造成其他问题。

幸运的是,我们使用了之前提到的方式来构建服务系统。小型的Seneca模块都是一个个高内聚、低耦合的插件。

这样一来,针对以上问题的解决方案变得相当简单,即只需在多台机器上部署邮件服务(email.js):

var seneca = require("seneca")().use("email");
seneca.listen({port: 1932, host: "new-email-service-ip"});

同时,创建一个指向服务端的Seneca客户端:

var seneca = require("seneca")()
  .use("email")
  .use("sms")
  .use("post");
seneca.listen({port: 1932, host: "10.0.0.7"});

// 通过“seneca”与已有的邮件服务交互

var senecaEmail = require("seneca").client({host: "new-email-service-ip", port: 1932});

// 通过“senecaEmail”与新的邮件服务交互

现在,当我们调用act时,senecaEmail 变量便能够与远端邮件服务端交互了,我们也就达成了目标,那就是扩展了第一个微服务。

数据才是分割单块软件的主要问题

数据存储存在问题,如果你的应用已经自然增长了多年,那么数据库也是如此,这将导致难以处理数据库的重大变更。

例如,在构建金融服务系统时,面向微服务架构的主要痛点就是缺乏事务性。对于跟钱打交道的组件来说,必须保证每一次独立操作的数据一致性而不是最终一致性。

使用微服务搭建金融系统的主要原则是:涉及金钱处理的部分不要过于微服务化,而对于其他例如邮件、短信和用户注册等辅助模块,则适合微服务化。

组织架构适配

每当谈及微服务的组织架构适配时,自治才是关键因素。为了保证构建微服务的敏捷性,每个团队都必须保持自治,这也意味着要确保技术的自主选择权,如下所示:

  • 使用的语言。
  • 代码规范。
  • 解决问题的模式。
  • 各类工具的选择,比如软件的构建、测试、调试及部署工具。

如果你的代码规范过于复杂,那么在某个团队想要向其他团队的微服务提交代码时就会遇到很多阻力(切记,一个团队拥有自己的服务,但是其他团队的成员也可以对这个服务做出贡献)。

小结

在本章中,我们讨论了如何构建可以根据业务需求切分为微服务的单块应用。正如你所学到的,为了构建出高质量的软件,我们必须牢记**原子性、一致性、隔离性、持久性(ACID)**这4个设计原则。