http://www.infoq.com/cn/articles/spring-cloud-service-wiring
主要結論
- Spring Cloud為微服務系統中相互依賴的服務提供了豐富的連接選項。
- Spring Cloud Config為配置數據提供了通過Git管理的版本控制機制,並能在無需重啟動的情況下對此類數據進行動態刷新。
- 通過將Spring Cloud與Netflix Eureka以及Ribbon組件配合使用,應用程序服務將能用更為動態的方式相互發現,並能通過專用負載平衡器代理將負載平衡決策推送至客戶端服務。
- 系統的邊緣位置依然有諸如AWS ELB等負載平衡解決方案的一席之地,這里的傳入流量還無法控制。
- 針對中間層微服務之間的通信,Ribbon提供了一種更為可靠和高性能的解決方案,該方案不依賴特定的雲供應商。
簡介
隨着轉向基於微服務的體系結構,我們開始面臨一項重要決策:如何將不同服務連接在一起?單層系統(Monolithic system)中的不同組件可以通過簡單的方法調用進行通信,但微服務系統中的不同組件很有可能需要借助REST、Web服務,或某種類似RPC的機制實現網絡通信。
在單層系統中,可以完全避免服的連接方面遇到的問題,讓每個組件根據需求創建自己的依存項。但實際上我們很少會這樣做。組件和依存項之間的這種緊密耦合會使得系統過於僵硬,會對測試工作產生不利影響。此時我們會選擇讓組件的依存關系外化(Externalise),並在創建組件時直接注入這樣的關系,依存關系的注入主要可用於類和對象的連接。
假設打算通過一系列微服務實現一個應用程序,可以使用與單層系統類似的連接選項。依存項的地址可硬編碼到程序中,借此將服務緊密連接在一起。或者也可以將所依賴的服務地址外化,並在部署或運行的時候提供這些服務。本文將介紹在微服務應用程序的構建過程中,如何通過Spring Boot和Spring Cloud實現這些選項。
我們假設了下圖所示的一個名為repmax
的簡單微服務系統:
Repmax系統
Repmax應用程序可以記錄追蹤用戶的舉重成績,並用每次舉重前五名選手的成績生成排行榜。其中logbook
服務負責通過UI收集每次練習的數據並存儲每位用戶的完整歷史信息。當用戶在練習完畢錄入成績后,logbook
會將此次舉重的詳細信息發送至leaderboard
服務。
從圖中可以看到,logbook
服務需要依賴leaderboard
服務。從最佳實踐的角度考慮,我們將這個依存項抽象為LeaderBoardApi
接口:
public interface LeaderBoardApi { void recordLift(Lift lift); }
由於這是個Spring應用程序,需要使用RestTemplate
處理logbook和leaderboard服務之間通信的細節:
abstract class AbstractLeaderBoardApi implements LeaderBoardApi { private final RestTemplate restTemplate; public AbstractLeaderBoardApi() { RestTemplate restTemplate = new RestTemplate(); restTemplate.getMessageConverters().add(new FormHttpMessageConverter()); this.restTemplate = restTemplate; } @Override public final void recordLift(Lifter lifter, Lift lift) { URI url = URI.create(String.format("%s/lifts", getLeaderBoardAddress())); MultiValueMap<String, String> params = new LinkedMultiValueMap<>(); params.set("exerciseName", lift.getDescription()); params.set("lifterName", lifter.getFullName()); params.set("reps", Integer.toString(lift.getReps())); params.set("weight", Double.toString(lift.getWeight())); this.restTemplate.postForLocation(url, params); } protected abstract String getLeaderBoardAddress(); }
AbstractLeaderBoardApi
類可以捕獲針對leaderboard
服務創建POST
請求的全部邏輯,並可通過子類指定leaderboard
服務的准確地址。
將多個微服務相互連接最簡單的方法可能就是將每個服務需要的依存項地址硬編碼到程序中。這相當於在單層系統的世界中通過硬編碼的方式實現依賴項的具現化(Instantiation)。這一點可以在StaticWiredLeaderBoardApi
類中輕松實現:
public class StaticWiredLeaderBoardApi extends AbstractLeaderBoardApi { @Override protected String getLeaderBoardAddress() { return "http://localhost:8082"; } }
硬編碼方式指定的服務地址使得我們能夠快速上手,但在現實環境中這樣做有些不太實際。服務的每個不同部署需要自定義編譯,這一做法很快會變得充滿痛苦並且容易出錯。
如果要部署的是單層系統,並且希望對應用程序進行重構以消除硬編碼的地址,首先需要將地址信息外化至配置文件。微服務應用程序也可以使用相似的方法:將地址信息推送至配置文件,並讓所實現的API從配置中讀取地址。
Spring Boot使得配置參數的定義和注入工作變得更簡單。只要將地址參數加入application.properties
文件即可:
leaderboard.url=http://localhost:8082
隨后可以使用@Value
標注(Annotation)將這個參數注入ConfigurableLeaderBoardApi
:
public class ConfigurableLeaderBoardApi extends AbstractLeaderBoardApi { private final String leaderBoardAddress; @Autowired public ConfigurableLeaderBoardApi(@Value("${leaderboard.url}") String leaderBoardAddress) { this.leaderBoardAddress = leaderBoardAddress; } @Override protected String getLeaderBoardAddress() { return this.leaderBoardAddress; } }
Spring Boot對Externalized Configuration的支持使得我們不僅可以通過修改配置文件更改leaderboard.url
的值,而且可以在啟動應用程序時指定環境變量:
LEADERBOARD_URL=http://repmax.skipjaq.com/leaderboard java -jar repmax-logbook-1.0.0-RELEASE.jar
隨后即可在不更改代碼的情況下將logbook
服務實例指向任何一個leaderboard
服務實例。如果系統符合12 factor原則,環境中很可能已經包含了連接信息,因此可通過簡單的工作將其直接映射至應用程序。
諸如Cloud Foundry和Heroku等平台即服務(PaaS)系統會將數據庫和消息系統等托管服務的連接信息暴露到環境中,這樣即可用完全相同的方式連接這些依賴項。實際上,將兩個服務連接在一起,以及將一個服務與相應的數據存儲連接在一起,這兩種做法並沒有什么本質差異,都只是將兩個分布式系統連接在一起。
超越點對點連接
對於比較簡單的應用程序,為依存項的地址使用外部配置就已足夠。然而對於任何規模的應用程序,我們需要的可能不僅僅是簡單的點對點連接,可能還希望實現某種形式的負載平衡。
如果每個服務都直接依賴某一下游服務實例,下游出現的任何故障都可能造成最終用戶遇到嚴重問題。同理,如果下游服務超載,用戶將會面臨響應時間延長的問題。此時我們需要的是負載平衡。
與其直接依賴一個下游實例,我們更希望通過一組下游服務實例分攤負載。如果這些實例中有一個故障或超載,其他實例可以接手處理任務。為這種體系結構實現負載平衡的最簡單方法是使用負載平衡代理。下圖展示了在Amazon Web Services部署中使用Elastic Load Balancing實現這種方式的具體做法:
為排行榜應用ELB
這種情況下無需讓logbook
服務直接與leaderboard
服務通信,而是可以使用ELB對每個請求進行路由。ELB會將每個請求路由至某一后端leaderboard
服務。通過讓ELB充當中介,可將負載分攤到多個leaderboard實例,這有助於減少每個實例的負擔。
ELB的負載平衡是動態的,運行過程中可以給后端添加新的實例,因此如果傳入流量激增,即可啟動更多leaderboard
實例加以應對。
Spring Boot應用程序可使用actuator暴露供ELB定期監控的/health
端點。能夠響應此類運行狀況檢查操作的實例會保留在ELB的活躍集(Active set)中,如果多次檢查均未響應,相應的實例會從服務中移除。
在我們的系統中,leaderboard
服務不是唯一能通過負載平衡獲益的服務。logbook
服務以及前端UI均能借助負載平衡機制實現更好的可擴展性和彈性。
動態重配置
無論使用AWS ELB、Google Compute Load Balancing,或者使用HAProxy或NGINX自行搭建負載平衡代理,都需要將服務與負載平衡器相互連接。
此時一種方法是為每個負載平衡器提供一個「眾所周知」的DNS名稱,例如leaderboard.repmax.local
,並使用上文提到的靜態連接方式將其硬編碼至應用程序中。由於DNS系統本身的靈活性,這種方法已經可以做到相當靈活。然而使用硬編碼的名稱意味着要在運行服務的每個環境中配置一台DNS服務器。在開發過程中,由於需要為多種多樣的操作系統提供支持,提供定制化DNS的操作就顯得尤為麻煩。此時更好的做法是使用類似上文leaderboard.url
的例子那樣,將負載平衡器的地址自然地注入服務。
在AWS和GCP等雲環境中,負載平衡器(及其地址)會頻繁變動。當負載平衡器被刪除並重建后,通常會使用一個新的地址。如果將負載平衡器的地址硬編碼到程序中,為了應對地址的變化,必須在每次改變后重新編譯代碼。但通過使用外化的配置,只需更改配置文件並重啟動服務即可。
為了應對負載平衡器地址不斷變化這一本質,DNS是一種很方便的做法。每個負載平衡器都可分配一個固定的DNS名稱,並將這個名稱注入所調用的服務。在重建負載平衡器時,其DNS名稱可重映射至負載平衡器的新地址。如果准備在環境中運行DNS服務器,就很適合使用這種基於DNS的方法。如果不想運行DNS,但依然希望對負載平衡器進行動態重配置,此時可以考慮使用Spring Cloud Config。
Spring Cloud Config會運行一個名為Config Server的小巧服務,並通過REST API提供可集中訪問的配置數據。默認情況下配置數據存儲在一個Git倉庫中,並可通過標准的PropertySource
抽象暴露給Spring Boot服務。使用PropertySource
可將本地屬性文件中包含的配置與Config Server中存儲的配置無縫結合在一起。對於本地開發,可以使用來自本地屬性文件的配置,並只在將應用程序部署在現實環境時才覆蓋這些配置信息。
為使用Spring Cloud Config取代ConfigurableLeaderBoardApi
,首先可以用所需配置初始化一個Git代碼庫:
mkdir -p ~/dev/repmax-config-repo cd ~/dev/repmax-config-repo git init echo 'leaderboard.lb.url=http://some.lb.address' >> repmax.properties git add repmax.properties git commit -m 'LB config for the leaderboard service'
repmax.properties
文件中包含repmax
應用程序default
配置文件的設置。如果希望將配置加入其他配置文件,例如加入development
,此時只需要提交另一個名為repmax-development.properties
的文件即可。
若要運行Config Server,可以運行spring-cloud-config-server
項目提供的默認Config Server,或自行創建一個簡單的Spring Boot項目並承載下列Config Server:
@SpringBootApplication @EnableConfigServer public class RepmaxConfigServerApplication { public static void main(String[] args) { SpringApplication.run(RepmaxConfigServerApplication.class, args); } }
其中@EnableConfigServer
標注可用於通過小巧的Spring Boot應用程序啟動Config Server。隨后可以用spring.cloud.config.server.git.uri
屬性將Config Server指向Git代碼庫。對於本地測試工作,可將其加入Config Server應用程序的application.properties
文件:
spring.cloud.config.server.git.uri=file://${user.home}/dev/repmax-config-repo
通過這種方式,團隊中的每位開發者都可以在自己的計算機上啟動Config Server,並通過本地Git代碼庫進行測試。若要驗證repmax
應用程序的屬性是否已通過Config Server暴露,可在Config Server運行后使用瀏覽器訪問http://localhost:8888/repmax/default
:
在Config Server中瀏覽配置信息
從圖中可以看到,leaderboard.lb.url
屬性已通過repmax.properties
文件暴露,其值為http://localhost:8083
。JSONT載荷的version
屬性顯示了加載配置時所用的Git版本。
在生產環境中,可以充分借助PropertySource
抽象將Git代碼庫的名稱以環境變量的方式提供:
SPRING_CLOUD_CONFIG_SERVER_GIT_URI=https://gitlab.com/rdh/repmax-config-repo java -jar repmax-config-server-1.0.0-RELEASE.jar
Spring Cloud Config Client
修改logbook服務使其從新增的Config Server中讀取配置,這一過程只需要幾個簡單的步驟。首先在build.gradle
文件中為spring-cloud-starter-config`增加一個依存項;
compile("org.springframework.cloud:spring-cloud-starter-config:1.1.1.BUILD-SNAPSHOT")
隨后提供Config Client所需的基本自舉配置。考慮到Config Server會通過一個名為repmax.properties
的文件暴露配置,此時要向Config Client提供應用程序的名稱。此類自舉配置位於logbook
服務的bootstrap.properties
文件中:
spring.application.name=repmax
默認情況下,Config Client會通過http://localhost:8888
查找Config Server。若要修改這個地址,可在啟動客戶端應用程序時指定SPRING_CLOUD_CONFIG_URI
環境。
一旦客戶端,即本例中的logbook
啟動后,即可訪問http://localhost:8081/env
以確認來自Config Server的配置是否正確加載:
確認Config Client可以訪問Config Server
將logbook
服務配置為使用Config Client后,可修改ConfigurableLeaderBoardApi
以從Config Server暴露的leaderboard.lb.url
屬性中獲取負載平衡器的地址。
啟用動態刷新
通過將配置信息集中存儲在一個位置,可以輕松更改repmax
配置,使其能夠被所有服務直接使用。然而為了應用這些配置依然需要重啟動服務。實際上可以通過更好的方式實現。可以借助Spring Boot提供的@ConfigurationProperties
標注將配置直接映射給JavaBeans。Spring Cloud Config更進一步為每個客戶端服務暴露了一個/refresh
端點。帶有@ConfigurationProperties
標注的Bean可在通過/refresh
端點觸發刷新后更新自己的屬性。
任何Bean均可添加@ConfigurationProperties
標注,但是有必要對刷新操作進行限制,只應用於包含配置數據的Bean。為此可以用一個專門用於保存leaderboard
地址的LeaderboardConfig
Bean:
@ConfigurationProperties("leaderboard.lb") public class LeaderboardConfig { private volatile String url; public String getUrl() { return this.url; } public void setUrl(String url) { this.url = url; } }
@ConfigurationProperties
標注的值實際上是希望映射至Bean的配置值的前綴。隨后每個值可使用標准的JavaBean命名規則進行映射。這種情況下,url
Bean屬性可映射至配置中的leaderboard.lb.url
。
隨后要修改ConfigurableLeaderBoardApi
以接受LeaderboardConfig
實例,而非原始的leaderboard
地址:
public class ConfigurableLeaderBoardApi extends AbstractLeaderBoardApi { private final LeaderboardConfig config; @Autowired public ConfigurableLeaderBoardApi(LeaderboardConfig config) { this.config = config; } @Override protected String getLeaderBoardAddress() { return this.config.getLeaderboardAddress(); } }
為了觸發配置刷新操作,可向logbook
服務的/refresh
端點發送一個HTTP POST
請求:
curl -X POST http://localhost:8081/refresh
有關服務發現
通過使用Spring Cloud Config,並在logbook
和leaderboard
服務之間使用負載平衡代理,應用程序已經基本完成了。然而還需要進行一定的完善。
如果在AWS或GCP中部署,可以充分利用這些環境中提供的高彈性負載平衡器,但如果使用諸如HAProxy或NGINX之類的市售負載平衡代理產品,此時必須自行處理服務的發現和注冊工作。leaderboard
的每個新增實例,以及每個因為故障要從代理中移除的實例,都必須在代理中進行配置。我們真正需要的是動態發現技術,每個服務實例都需要能自行注冊以供發現和使用。
使用負載平衡代理的情況下還存在另一個潛在問題:可靠性。由於所有流量需要通過代理進行路由,因此整個系統的可靠性都受制於代理本身的可靠性。代理停機同時會導致整個系統停機。此外還需要考慮客戶端和代理之間,以及代理和服務器之間通信所產生的開銷。
為解決這些問題Netflix開發了Eureka。Eureka是一種用於提供服務注冊和發現能力的客戶端-服務器系統。服務實例啟動后,可將自己與Eureka服務器進行注冊。諸如logbook
等客戶端服務可以聯系Eureka服務器以獲取可用服務列表。客戶端和服務器之間采用了點對點的通信方式。
Eureka使得我們不再需要代理,這樣可以改善整個系統的可靠性。如果leaderboard
代理故障,logbook
服務將完全無法聯系leaderboard
服務。通過使用Eureka,logbook
可以知道所有可用leaderboard
實例,就算一個實例故障,logbook
也只需要聯系下一個leaderboard
實例並重試。
那么在整個系統體系結構中,Eureka服務器本身是否會成為一個故障點?拋開為Eureka服務器創建集群這種做法不談,每個Eureka客戶端都可以在本地緩存服務的運行狀態。只要在Eureka服務器上運行了服務監視器,例如systemd
,就可以順利應對偶爾出現的崩潰等問題。
與Config Server類似,Eureka服務器也可以作為一個小巧的Spring Boot應用程序來運行:
@SpringBootApplication @EnableEurekaServer public class RepmaxEurekaServerApplication { public static void main(String[] args) { SpringApplication.run(RepmaxEurekaServerApplication.class, args); } }
在應用程序啟動時,@EnableEurekaServer
標注會通知Spring Boot啟動Eureka。出於高可用目的,默認情況下服務器會嘗試聯系其他服務器。在獨立安裝的情況下可以考慮在application.yml
中關閉該功能:
server: port: 8761 eureka: instance: hostname: localhost client: registerWithEureka: false fetchRegistry: false
請注意,按照慣例可在8761
端口運行Eureka服務器。訪問http://localhost:8761
可以查看Eureka儀表板。由於目前尚未注冊任何服務,可用實例列表中什么也沒顯示:
空白的Eureka儀表板
若要將leaderboard
服務注冊至Eureka,可為該應用程序類添加一個@EnableEurekaClient
標注。隨后通過application.properties
告訴Eureka客戶端在哪里可以找到服務器,以及應用程序在服務器上注冊時所用的名稱:
spring.application.name=repmax-leaderboard eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka
leaderboard
服務啟動時,Spring Boot會檢測到@EnableEurekaClient
標注並啟動Eureka客戶端,隨后該客戶端會將leaderboard
服務注冊至Eureka服務器。Eureka儀表板將會顯示出新注冊的服務:
服務注冊后Eureka儀表板顯示的內容
logbook
服務可以通過與leaderboard
服務相同的方式配置為Eureka客戶端,需要添加@EnableEurekaClient
標注並配置Eureka服務URL。
通過在logbook
服務中啟用Eureka客戶端,Spring Cloud會暴露一個用於查詢服務實例的DiscoveryClient
Bean:
@Component public class DiscoveryLeaderBoardApi extends AbstractLeaderBoardApi { public DiscoveryLeaderBoardApi(DiscoveryClient discoveryClient) { this.discoveryClient = discoveryClient; } private final DiscoveryClient discoveryClient; @Override protected String getLeaderBoardAddress() { List<ServiceInstance> instances = this.discoveryClient.getInstances("repmax-leaderboard"); if(instances != null && !instances.isEmpty()) { ServiceInstance serviceInstance = instances.get(0); return String.format("http://%s:%d", serviceInstance.getHost(), serviceInstance.getPort()); } throw new IllegalStateException("Unable to locate a leaderboard service"); } }
調用DiscoveryClient.getInstances
可獲得ServiceInstances
列表,列表中每一項均對應了一個注冊到Eureka服務器的leaderboard
服務。從簡化的角度考慮,可以從列表中選擇第一項服務用於遠程調用。
客戶端的負載平衡
Eureka就位后,不同服務將能以動態的方式相互發現,並能直接相互通信,借此可避免負載平衡器代理所產生的開銷以及可能的故障點。當然這里也需要進行權衡,因為我們將有關負載平衡的復雜性轉嫁到了代碼中。
在這里可以看到,DiscoveryLeaderBoardApi.getLeaderBoardAddress
方法在每次遠程調用過程中,會直接選擇找到的第一個ServiceInstance
。借助這種方法可以方便地將負載分散到所有可用實例。此外本例中還可以通過Netflix Cloud的另一個組件處理客戶端的負載平衡:Ribbon。
將Ribbon與Spring Cloud以及現有的Eureka環境配合使用的方法很簡單。只需要在logbook
服務中添加針對spring-cloud-starter-ribbon
的依賴關系,並改為使用LoadBalancerClient
取代DiscoveryClient
即可:
public class RibbonLeaderBoardApi extends AbstractLeaderBoardApi { private final LoadBalancerClient loadBalancerClient; @Autowired public RibbonLeaderBoardApi(LoadBalancerClient loadBalancerClient) { this.loadBalancerClient = loadBalancerClient; } @Override protected String getLeaderBoardAddress() { ServiceInstance serviceInstance = this.loadBalancerClient.choose("repmax-leaderboard"); if (serviceInstance != null) { return String.format("http://%s:%d", serviceInstance.getHost(), serviceInstance.getPort()); } else { throw new IllegalStateException("Unable to locate a leaderboard service"); } } }
至此選擇ServiceInstance
的任務將由Ribbon負責,該功能可以智能地監控端點運行狀況,並通過內建機制實現負載平衡。
總結
本文介紹了各種將微服務連接在一起的方法。其中最簡單的方法可能就是將服務所需的每個依存項的地址硬編碼到程序中。這種方法可以幫助我們快速上手,但在現實環境中實用性很低。
對於現實世界中最基本的應用程序,通過外部配置使用application.properties
文件指定依存項地址這種做法已經足夠了。諸如Cloud Foundry和Heroku等平台即服務(PaaS)系統通過暴露連接信息,使得我們能夠用完全相同的方式連接這些依賴項。
然而更大規模的應用程序不僅需要簡單的點對點連接,還需要使用某種形式的負載平衡。Spring Cloud Config與負載平衡代理的緊密結合是一種解決方案,但如果使用諸如HAProxy或NGINX等市售的負載平衡代理,就只能自行處理服務的發現和注冊過程,代理也有可能成為所有流量的一個故障點。通過使用Netflix的Eureka和Ribbon組件,應用程序中的服務將能以動態的方式互相查找,並能將有關負載平衡的決策從專門的負載平衡器代理交由客戶端服務來處理。
由於無法控制中間層微服務之間通信產生的傳入流量,諸如AWS ELB等負載平衡解決方案在系統邊緣可能依然占有一席之地,Ribbon提供了一種不依賴具體的雲供應商,可靠性和性能更為出色的解決方案。
關於作者
Rob Harrop是Skipjaq公司CTO,該公司致力於通過機器學習技術解決績效管理方面遇到的問題。在加入Skipjaq前,Rob以SpringSource共同創始人的身份廣為人知,這家軟件公司開發了大獲成功的Spring框架。在SpringSource任職期間,他是Spring框架的核心貢獻者,並領導了dm Server(現名為Eclipse Virgo)的開發團隊。在加入SpringSource前,(當時僅19歲的)Rob是英國曼徹斯特顧問公司Cake Solutions的共同創始人兼CTO。作為廣受敬重的作者、演講人和講師,Rob經常撰寫和探討有關大規模系統、雲體系結構,以及功能編程(Functional programming)的話題。他出版的著作包括極受歡迎的Spring框架參考書《Pro Spring》。