新聞中心
Rails 應(yīng)用有各種類型,規(guī)模也各有不同。有的是一個(gè)獨(dú)立的龐大的應(yīng)用,全部應(yīng)用都在同一個(gè)位置(包括管理界面、API、前端部分以及所有需要的模塊)。另一些應(yīng)用則是劃分成一系列的微服務(wù),服務(wù)之間互相通信,這樣可以把整個(gè)應(yīng)用切分成更易管理的部分。
這種微服務(wù)的架構(gòu)被稱為面向服務(wù)的架構(gòu)( SOA )。雖然我見到過的 Rails 應(yīng)用通常都傾向于成為獨(dú)立的程序,不過開發(fā)者也完全可以選擇讓多個(gè) Rails 程序,以及與其他語(yǔ)言或者框架編寫的服務(wù)一起工作來完成任務(wù)。
獨(dú)立的程序不意味著一定寫的不好,但是寫的差的獨(dú)立程序被拆成微服務(wù)后大多也是很糟糕的。有多種方式可以讓你寫出清晰的(更容易測(cè)試的)代碼,同時(shí)在需要拆分應(yīng)用的時(shí)候也更輕松。
使用微服務(wù)架構(gòu)的 Rails 應(yīng)用的用例本文會(huì)討論如何實(shí)現(xiàn)一個(gè) CMS 的網(wǎng)站??梢约僭O(shè)是一家大的報(bào)紙或者博客,有很多作者負(fù)責(zé)投稿,用戶可以按主題訂閱內(nèi)容。
Martin Fowler 有一篇很不錯(cuò)的文章,介紹了為什么編輯和發(fā)布應(yīng)該分成兩個(gè)不同的系統(tǒng)。我們的用例與此類似,另外我們還要添加兩個(gè)模塊:通知和訂閱。
我們的 CMS 現(xiàn)在有四個(gè)主要的模塊:
CMS 編輯器:作者和編輯用來創(chuàng)建、編輯和發(fā)布文章。
公共的網(wǎng)站:對(duì)外提供服務(wù),瀏覽已發(fā)布的文章。
通知:通知訂閱者有新發(fā)布的文章。
訂閱:管理用戶賬號(hào)和訂閱。
Rails 應(yīng)用需要支持 SOA 嗎?是選擇獨(dú)立程序還是構(gòu)建成微服務(wù)?這里沒有對(duì)和錯(cuò)之分,不過下面的問題能幫你做出決定。
團(tuán)隊(duì)的組織結(jié)構(gòu)是怎樣的?是否選擇支持 SOA 通常與技術(shù)無關(guān),而是在于開發(fā)團(tuán)隊(duì)的組織結(jié)構(gòu)。
由四個(gè)團(tuán)隊(duì)分別負(fù)責(zé)一個(gè)主要的模塊,比所有人在整個(gè)系統(tǒng)上一起工作要靠譜一些。如果你只有一個(gè)團(tuán)隊(duì)或者少數(shù)幾個(gè)開發(fā)人員,一開始就決定采用微服務(wù)架構(gòu)實(shí)際上會(huì)減慢開發(fā)的速度,這是因?yàn)樾枰獮樗膫€(gè)不同的組件直接的通信以及部署增加開發(fā)量。
不同的模塊規(guī)模不一樣?對(duì)于本文的例子,有一個(gè)問題提現(xiàn)的很好,對(duì)外提供服務(wù)的公共網(wǎng)站肯定要比作者和編輯使用的 CMS 編輯器的訪問壓力要大很多。
如果這些模塊都部署成分離的系統(tǒng),我們就可以單獨(dú)的控制它們的規(guī)模,為系統(tǒng)中不同的部分采用不同的緩存技術(shù)。你當(dāng)然還是可以堅(jiān)持采用單一的系統(tǒng),但是那樣的話你就只能為整個(gè)系統(tǒng)一次性確定其規(guī)模,而不是對(duì)不同的組件分開處理。
不同的模塊使用不同的技術(shù)?對(duì)于 CMS 編輯器,你也許想使用 Single Page Application (SPA),采用 React 或者 Angular 技術(shù)。而對(duì)外的網(wǎng)站,會(huì)使用更傳統(tǒng)一些的服務(wù)端渲染的 Rails 應(yīng)用(為了支持 SEO)。也許通知模塊更適合 Elixir,因?yàn)檫@個(gè)語(yǔ)言對(duì)并發(fā)和并行處理支持不錯(cuò)。
模塊的分離,使得你可以為每個(gè)模塊選擇最適合的編程語(yǔ)言。
邊界定義現(xiàn)在最重要的事情是定義好系統(tǒng)中模塊之間的邊界。
系統(tǒng)中的某個(gè)部分可能是某個(gè)外部
Server
的
Client。使用方法調(diào)用還是基于 HTTP 都不重要,它只需要知道它需要與系統(tǒng)中的其他部分進(jìn)行通信。
為此我們需要定義清晰的邊界。
當(dāng)一篇文章發(fā)布時(shí),會(huì)發(fā)生兩件事:
首先會(huì)把文章的發(fā)布版本發(fā)送給對(duì)外的網(wǎng)站,它會(huì)返回一個(gè)發(fā)布后的 URL。
然后我們把剛創(chuàng)建的公開的 URL、話題、標(biāo)題發(fā)送到通知模塊,后者會(huì)通知到所有對(duì)話題感興趣的訂閱者。這一步可以是異步的,因?yàn)橥ǔ?huì)耗費(fèi)一些時(shí)間來通知到每一個(gè)用戶,并且這個(gè)通知是不會(huì)有反饋的。
例如,下面的代碼用來發(fā)布一篇文章。文章本身不會(huì)關(guān)心服務(wù)是通過方法調(diào)用還是 HTTP 來調(diào)用的。
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這種方式也可以讓我們方便測(cè)試 Publisher 類的功能,我們可以使用 TestPublisherService 來做測(cè)試,它會(huì)返回預(yù)定義的應(yīng)答。
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實(shí)際上 PublisherService 的具體實(shí)現(xiàn)還沒有完成,但是這不妨礙我們?yōu)榭蛻舳耍ù颂幨?Publisher)編寫測(cè)試用例來保證其按預(yù)期工作。
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服務(wù)間通信服務(wù)之間需要能夠互相通信。對(duì)此作為 Ruby 程序員應(yīng)該是很熟悉了,即使之前沒有做過微服務(wù)的程序。
調(diào)用某個(gè)對(duì)象的方法,只需要給它發(fā)送消息,例如調(diào)用 Time.send(:now) 就可以改變 Time.now。不管是通過方法調(diào)用還是基于 HTTP 進(jìn)行通信,原理是一樣的。我們要做的是給系統(tǒng)的其他部分發(fā)送消息,通常還需要有回應(yīng)。
使用 HTTP 協(xié)議和微服務(wù)通訊當(dāng)你的應(yīng)用需要一個(gè)來自服務(wù)端的立即響應(yīng)才能繼續(xù)執(zhí)行的時(shí)候,使用 HTTP 協(xié)議來交互將是不二的選擇。
當(dāng)你需要一個(gè)立即響應(yīng)的時(shí)候,HTTP 協(xié)議通訊將是不二的選擇。
在下面的例子中,PublisherService 類實(shí)現(xiàn)了使用 HTTP Post 方法來和后端的 Faraday 服務(wù)模塊進(jìn)行通訊。
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這段代碼簡(jiǎn)單來說就是構(gòu)造了一個(gè)需要發(fā)送給后端的數(shù)據(jù),然后通過 HTTP Post 發(fā)送到后端,并且處理從后端的返回的數(shù)據(jù)。但后端返回了正確的數(shù)據(jù),程序?qū)⒔忉屵@個(gè)數(shù)據(jù),否則程序?qū)伋鲆粋€(gè)異常。在后面我們將對(duì)這個(gè)代碼進(jìn)行詳細(xì)地解釋。
在代碼中,后端服務(wù)程序的地址保存在常量 Cms::PUBLIC_WEBSITE_URL中,這個(gè)常量的值是通過初始化代碼設(shè)置的。這樣做的好處就是允許我們使用環(huán)境變量,根據(jù)部署環(huán)境的不同(比如開發(fā)環(huán)境或者生產(chǎn)環(huán)境)來給它配置不同的值。
Cms::PUBLIC_WEBSITE_URL = ENV['PUBLIC_WEBSITE_URL'] || 'http://localhost:3000'測(cè)試我們的服務(wù)現(xiàn)在讓我們來測(cè)試 PublisherService 類,看看它是否正常工作。
在這個(gè)測(cè)試中,由于我們是在開發(fā)環(huán)境中做測(cè)試,所以并不能保證后端服務(wù)一直可用,因此我們將使用 WebMock 模塊來模擬到后端的 HTTP 請(qǐng)求,并返回需要的數(shù)據(jù)。
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處理調(diào)用失敗在系統(tǒng)使用過程中,有一件事情是絕對(duì)不可避免的,那就是對(duì)于服務(wù)端的調(diào)用可能失敗(服務(wù)暫時(shí)不可用或者網(wǎng)絡(luò)通信超市),我們的代碼應(yīng)該要能夠正確處理這些異常。
當(dāng)遠(yuǎn)端服務(wù)不可用的時(shí)候,系統(tǒng)應(yīng)該如何響應(yīng)完全取決于開發(fā)者。在我們的 CMS 應(yīng)用中,當(dāng)遠(yuǎn)端服務(wù)不可用的時(shí)候,用戶仍然可以創(chuàng)建和編輯文章,只是不能發(fā)布任何文章。
在上面的測(cè)試?yán)又?,代碼包含了對(duì) HTTP Status Code 500 (服務(wù)段出現(xiàn)異常)的處理。當(dāng)測(cè)試代碼收到 500 Status Code 的時(shí)候,代碼將拋出 PublisherService::ServiceResponseError 這個(gè)異常。 ServiceResponseError 這個(gè)異常類繼承自 Error 類,目前這個(gè)類并沒有對(duì)外提供任何有用的信息,僅僅表示發(fā)生了一個(gè)錯(cuò)誤。下面是這個(gè)類的相關(guān)代碼。
class HttpService class Error < RuntimeError end class ServiceResponseError < Error end end在 Martin Fowler 的一篇文章中,提出了另外一種處理服務(wù)不可用的方法(在他的文章中,他把這種方法叫做 CircuitBreaker 模式)。簡(jiǎn)單來說,這個(gè)模式的任務(wù)就是通過某種方式檢測(cè)遠(yuǎn)端服務(wù)是否運(yùn)作正常。如果運(yùn)作不正常,它將阻止對(duì)響應(yīng)遠(yuǎn)端服務(wù)的調(diào)用。
我們也可以通過讓我們的應(yīng)用感知遠(yuǎn)端服務(wù)的狀態(tài)并且做出適當(dāng)?shù)姆磻?yīng)來讓我們的應(yīng)用更強(qiáng)壯。這種系統(tǒng)行為的改變,我們既可以通過類似 CircuitBreaker 的模式來自動(dòng)實(shí)現(xiàn),也可以通過用戶手動(dòng)關(guān)閉系統(tǒng)的某些功能來實(shí)現(xiàn)。
在我們的例子中,如果我們可以在現(xiàn)實(shí) Publish 按鈕之前檢查一下遠(yuǎn)端 Publish 服務(wù)是否可用,那么我們就可以直接避免對(duì)不可用服務(wù)的調(diào)用。
使用隊(duì)列進(jìn)行通信HTTP 并非是與其他服務(wù)通信的唯一方式。隊(duì)列是不同的服務(wù)之間傳遞異步消息的很好的選擇。如果對(duì)于要做的事情不需要消息接收者立刻反饋,那就非常適合這種方式(例如發(fā)送郵件)。
我們的 CMS 應(yīng)用中,文章發(fā)布后,訂閱文章的主題的用戶會(huì)被通知到(通過郵件,或者網(wǎng)站通知或者推送消息),告知他們有感興趣的文章被發(fā)布。我們的程序并不需要
Notifier服務(wù)的反饋,只需要把消息發(fā)給它就行了。
之前的一篇文章,我介紹了如何使用ActiveJob,Rails 自帶的,用來處理這種后臺(tái)或者異步處理的任務(wù)。
ActiveJob 要求接收代碼也需要運(yùn)行在 Rails 環(huán)境,不過它確實(shí)是一種很好的選擇,簡(jiǎn)單易用。
使用 RabbitMQRabbitMQ是 Rails(以及 Ruby)之外的另一個(gè)選擇,可以作為不同的服務(wù)之間的一個(gè)通用的消息處理系統(tǒng)。通過 RabbitMQ 也可以處理遠(yuǎn)程方法調(diào)用(RPC),不過更多的是使用 RabbitMQ 向其他服務(wù)方式異步消息。這里有很好的 Ruby 的使用教程。
下面的類用于向
Notifier服務(wù)發(fā)送消息,通知有新文章發(fā)布。
代碼可以這樣調(diào)用:
NotifierService.new("Soccer", "My Hair Secrets", "http://localhost:3000/article/my-hair-secrets").call總結(jié)微服務(wù)并不可怕,不過確實(shí)需要仔細(xì)的處理。它會(huì)帶來很多好處。我的建議是從一個(gè)有著清晰邊界的小系統(tǒng)開始,這樣你可以很容易的劃分服務(wù)。
更多的服務(wù)意味著更多的開發(fā)運(yùn)維工作(你不再只是部署一個(gè)單獨(dú)的程序,而是需要部署多個(gè)小服務(wù)),這時(shí)你也許有興趣看一下我寫的如何部署到 Docker 容器。
名稱欄目:Rails微服務(wù)架構(gòu)
本文來源:http://fisionsoft.com.cn/article/chddic.html