Rails 微服务架构

jopen 9年前

Rails 应用有各种类型,规模也各有不同。有的是一个独立的庞大的应用,全部应用都在同一个位置(包括管理界面、API、前端部分以及所有需要的模块)。另一些应用则是划分成一系列的微服务,服务之间互相通信,这样可以把整个应用切分成更易管理的部分。

这种微服务的架构被称为面向服务的架构( SOA )。虽然我见到过的 Rails 应用通常都倾向于成为独立的程序,不过开发者也完全可以选择让多个 Rails 程序,以及与其他语言或者框架编写的服务一起工作来完成任务。

Rails应用通常是独立的程序,但是没有理由阻止你尝试微服务的架构。

独立的程序不意味着一定写的不好,但是写的差的独立程序被拆成微服务后大多也是很糟糕的。有多种方式可以让你写出清晰的(更容易测试的)代码,同时在需要拆分应用的时候也更轻松。

使用微服务架构的 Rails 应用的用例

本文会讨论如何实现一个 CMS 的网站。可以假设是一家大的报纸或者博客,有很多作者负责投稿,用户可以按主题订阅内容。

Martin Fowler 有一篇很不错的文章,介绍了为什么编辑和发布应该分成两个不同的系统。我们的用例与此类似,另外我们还要添加两个模块:通知和订阅。

我们的 CMS 现在有四个主要的模块:

  • CMS 编辑器:作者和编辑用来创建、编辑和发布文章。

  • 公共的网站:对外提供服务,浏览已发布的文章。

  • 通知: 通知订阅者有新发布的文章。

  • 订阅: 管理用户账号和订阅。

Rails 应用需要支持 SOA 吗?

是选择独立程序还是构建成微服务?这里没有对和错之分,不过下面的问题能帮你做出决定。

如何确定是选择独立程序还是微服务?

团队的组织结构是怎样的?

是否选择支持 SOA 通常与技术无关,而是在于开发团队的组织结构。

由四个团队分别负责一个主要的模块,比所有人在整个系统上一起工作要靠谱一些。如果你只有一个团队或者少数几个开发人员,一开始就决定采用微服务架构实际上会减慢开发的速度,这是因为需要为四个不同的组件直接的通信以及部署增加开发量。

不同的模块规模不一样?

对于本文的例子,有一个问题提现的很好,对外提供服务的公共网站肯定要比作者和编辑使用的 CMS 编辑器的访问压力要大很多。

如果这些模块都部署成分离的系统,我们就可以单独的控制它们的规模,为系统中不同的部分采用不同的缓存技术。你当然还是可以坚持采用单一的系统,但是那样的话你就只能为整个系统一次性确定其规模,而不是对不同的组件分开处理。

不同的模块使用不同的技术?

对于 CMS 编辑器,你也许想使用 Single Page Application (SPA),采用 React 或者 Angular 技术。而对外的网站,会使用更传统一些的服务端渲染的 Rails 应用(为了支持 SEO)。也许通知模块更适合 Elixir,因为这个语言对并发和并行处理支持不错。

模块的分离,使得你可以为每个模块选择最适合的编程语言。

边界定义

现在最重要的事情是定义好系统中模块之间的边界。

系统中的某个部分可能是某个外部 Server Client。使用方法调用还是基于 HTTP 都不重要,它只需要知道它需要与系统中的其他部分进行通信。

为此我们需要定义清晰的边界。

当一篇文章发布时,会发生两件事:

  1. 首先会把文章的发布版本发送给对外的网站,它会返回一个发布后的 URL。

  2. 然后我们把刚创建的公开的 URL、话题、标题发送到通知模块,后者会通知到所有对话题感兴趣的订阅者。这一步可以是异步的,因为通常会耗费一些时间来通知到每一个用户,并且这个通知是不会有反馈的。

例如,下面的代码用来发布一篇文章。文章本身不会关心服务是通过方法调用还是 HTTP 来调用的。

class Publisher     attr_reader :article, :service      def initialize(article, service)       @article = article       @service = service     end      def publish       mark_as_published call_service       article     end      private      def call_service       service.new(         author: article.author,         title: article.title,         slug: article.slug,         category: article.category,         body: article.body       ).call     end      def mark_as_published(published_url)       article.published_at = Time.zone.now       article.published_url = published_url     end   end

这种方式也可以让我们方便测试 Publisher 类的功能,我们可以使用 TestPublisherService 来做测试,它会返回预定义的应答。

require "rails_helper"    RSpec.describe Publisher, type: :model do      let(:article) {       OpenStruct.new({         author: 'Carlos Valderrama',         title: 'My Hair Secrets',         slug: 'my-hair-secrets',         category: 'Soccer',         body: "# My Hair Secrets\nHow hair was the secret to my soccer success."       })     }      class TestPublisherService < PublisherService       def call         "http://www.website.com/article/#{slug}"       end     end      describe 'publishes an article to public website' do       subject { Publisher.new(article, TestPublisherService) }        it 'sets published url' do        published_article = subject.publish        expect(published_article.published_url).to eq('http://www.website.com/article/my-hair-secrets')      end            it 'sets published at' do        published_article = subject.publish        expect(published_article.published_at).to be_a(Time)      end    end   end

实际上 PublisherService 的具体实现还没有完成,但是这不妨碍我们为客户端(此处是 Publisher)编写测试用例来保证其按预期工作。

class PublisherService     attr_reader :author, :title, :slug, :category, :body      def initialize(author:, title:, slug:, category:, body:)       @author = author       @title = title       @slug = slug       @category = category       @body = body     end      def call       # coming soon     end   end

服务间通信

服务之间需要能够互相通信。对此作为 Ruby 程序员应该是很熟悉了,即使之前没有做过微服务的程序。

调用某个对象的方法,只需要给它发送消息,例如调用 Time.send(:now) 就可以改变 Time.now。不管是通过方法调用还是基于 HTTP 进行通信,原理是一样的。我们要做的是给系统的其他部分发送消息,通常还需要有回应。

使用 HTTP 协议和微服务通讯

当你的应用需要一个来自服务端的立即响应才能继续执行的时候,使用 HTTP 协议来交互将是不二的选择。

当你需要一个立即响应的时候,HTTP 协议通讯将是不二的选择。

在下面的例子中,PublisherService 类实现了使用 HTTP Post 方法来和后端的 Faraday 服务模块进行通讯。

class PublisherService < HttpService    attr_reader :author, :title, :slug, :category, :body      def initialize(author:, title:, slug:, category:, body:)      @author   = author      @title    = title      @slug     = slug      @category = category      @body     = body    end      def call      post["published_url"]    end      private      def conn      Faraday.new(url: Cms::PUBLIC_WEBSITE_URL)    end      def post      resp = conn.post '/articles/publish', payload        if resp.success?        JSON.parse resp.body      else        raise ServiceResponseError      end    end      def payload      {author: author, title: title, slug: slug, category: category, body: body}    end  end

这段代码简单来说就是构造了一个需要发送给后端的数据,然后通过 HTTP Post 发送到后端,并且处理从后端的返回的数据。但后端返回了正确的数据,程序将解释这个数据,否则程序将抛出一个异常。在后面我们将对这个代码进行详细地解释。

在代码中,后端服务程序的地址保存在常量 Cms::PUBLIC_WEBSITE_URL中,这个常量的值是通过初始化代码设置的。这样做的好处就是允许我们使用环境变量,根据部署环境的不同(比如开发环境或者生产环境)来给它配置不同的值。

Cms::PUBLIC_WEBSITE_URL = ENV['PUBLIC_WEBSITE_URL'] || 'http://localhost:3000'

测试我们的服务

现在让我们来测试 PublisherService 类,看看它是否正常工作。

在这个测试中,由于我们是在开发环境中做测试,所以并不能保证后端服务一直可用,因此我们将使用 WebMock 模块来模拟到后端的 HTTP 请求,并返回需要的数据。

RSpec.describe PublisherService, type: :model do      let(:article) {      OpenStruct.new({        author:   'Carlos Valderrama',        title:    'My Hair Secrets',        slug:     'my-hair-secrets',        category: 'Soccer',        body:     "# My Hair Secrets\nHow hair was the secret to my soccer success."      })    }      describe 'call the publisher service' do      subject {        PublisherService.new(          author:   article.author,          title:    article.title,          slug:     article.slug,          category: article.category,          body:     article.body        )      }        let(:post_url) {        "#{Cms::PUBLIC_WEBSITE_URL}/articles/publish"      }        let(:payload) {        {published_url: 'http://www.website.com/article/my-hair-secrets'}.to_json      }        it 'parses response for published url' do        stub_request(:post, post_url).to_return(body: payload)        expect(subject.call).to eq('http://www.website.com/article/my-hair-secrets')      end        it 'raises exception on failure' do        stub_request(:post, post_url).to_return(status: 500)        expect{subject.call}.to raise_error(PublisherService::ServiceResponseError)      end    end    end

处理调用失败

在系统使用过程中,有一件事情是绝对不可避免的,那就是对于服务端的调用可能失败(服务暂时不可用或者网络通信超市),我们的代码应该要能够正确处理这些异常。

当远端服务不可用的时候,系统应该如何响应完全取决于开发者。在我们的 CMS 应用中,当远端服务不可用的时候,用户仍然可以创建和编辑文章,只是不能发布任何文章。

在上面的测试例子中,代码包含了对 HTTP Status Code 500 (服务段出现异常)的处理。当测试代码收到 500 Status Code 的时候,代码将抛出 PublisherService::ServiceResponseError 这个异常。 ServiceResponseError 这个异常类继承自 Error 类,目前这个类并没有对外提供任何有用的信息,仅仅表示发生了一个错误。下面是这个类的相关代码。

class HttpService      class Error < RuntimeError    end      class ServiceResponseError < Error    end    end

在 Martin Fowler 的一篇文章中,提出了另外一种处理服务不可用的方法(在他的文章中,他把这种方法叫做 CircuitBreaker 模式)。简单来说,这个模式的任务就是通过某种方式检测远端服务是否运作正常。如果运作不正常,它将阻止对响应远端服务的调用。

我们也可以通过让我们的应用感知远端服务的状态并且做出适当的反应来让我们的应用更强壮。这种系统行为的改变,我们既可以通过类似 CircuitBreaker 的模式来自动实现,也可以通过用户手动关闭系统的某些功能来实现。

在我们的例子中,如果我们可以在现实 Publish 按钮之前检查一下远端 Publish 服务是否可用,那么我们就可以直接避免对不可用服务的调用。

使用队列进行通信

 HTTP 并非是与其他服务通信的唯一方式。队列是不同的服务之间传递异步消息的很好的选择。如果对于要做的事情不需要消息接收者立刻反馈,那就非常适合这种方式(例如发送邮件)。

队列是不同的服务之间传递异步消息的很好的选择

我们的 CMS 应用中,文章发布后,订阅文章的主题的用户会被通知到(通过邮件,或者网站通知或者推送消息),告知他们有感兴趣的文章被发布。我们的程序并不需要 Notifier 服务的反馈,只需要把消息发给它就行了。

使用 Rails 的队列

之前的一篇文章,我介绍了如何使用ActiveJob,Rails 自带的,用来处理这种后台或者异步处理的任务。

ActiveJob 要求接收代码也需要运行在 Rails 环境,不过它确实是一种很好的选择,简单易用。

使用 RabbitMQ

RabbitMQ 是 Rails(以及 Ruby)之外的另一个选择,可以作为不同的服务之间的一个通用的消息处理系统。通过 RabbitMQ 也可以处理远程方法调用(RPC),不过更多的是使用 RabbitMQ 向其他服务方式异步消息。这里有很好的 Ruby 的使用教程。

下面的类用于向 Notifier  服务发送消息,通知有新文章发布。

class NotifierService      attr_reader :category, :title, :published_url      def initialize(category, title, published_url)      @category = category      @title = title      @published_url = published_url    end      def call      publish payload    end      private      def publish(data)      channel.default_exchange.publish(data, routing_key: queue.name)      connection.close    end      def payload      {category: category, title: title, published_url: published_url}.to_json    end      def connection      @conn ||= begin        conn = Bunny.new        conn.start      end    end      def channel      @channel ||= connection.create_channel    end      def queue      @queue ||= channel.queue 'notifier'    end    end

代码可以这样调用:

NotifierService.new("Soccer", "My Hair Secrets", "http://localhost:3000/article/my-hair-secrets").call

总结

微服务并不可怕,不过确实需要仔细的处理。它会带来很多好处。我的建议是从一个有着清晰边界的小系统开始,这样你可以很容易的划分服务。

微服务并不可怕,不过确实需要仔细的处理

更多的服务意味着更多的开发运维工作(你不再只是部署一个单独的程序,而是需要部署多个小服务),这时你也许有兴趣看一下我写的如何部署到 Docker 容器。