接收程序员的8点技术早餐

作者|ChristianPosta
译者|海松
1技术
本主题第二部分、第三部分和第四部分中涉及到的技术如下,这些技术在我们的实践过程中将具备一定的指导作用:
开发人员服务框架(SpringBoot[2],WildFly[3],WildFlySwarm[4])
API设计([5])
数据框架(SpringBootTeiid[6],[7])
集成工具(ApacheCamel[8])
服务网格(IstioServiceMesh[9])
数据库迁移工具(Liquibase[10])
Darklaunch/featureflag框架(FF4J[11])
部署/CI-CD平台(Kubernetes[12]/OpenShift[13])
Kubernetes开发工具([14])
测试工具(Arquillian[15],Pact[16]/ArquillianAlgeron[17],Hoverfly[18],Spring-BootTest[19],RestAssured[20],ArquillianCube[21])
如果你想一起动手实践,那么可以和我一起使用,我借用了该教程用以演示如何完成从单体应用到微服务的演变。你还可以在github上找到相关的代码和文档(文档还在编写中):https:///ticket-monster-msa/monolith
在第二部分中,我们开始添加一个将要从单体应用中剥离出来的微服务(Orders/Booking)。我们借助Hoverfly模拟探索合适的API设计来开始这一步工作。
2将API与实现进行对接

回顾下注意事项
在定义上,被抽取或新建的服务的数据模型和单体应用的数据模型紧耦合
单体应用很可能没有提供在合适层级获取数据的API
即使我们获取了数据,也需要大量的代码样例来进行数据转换
我们可以临时性的直接访问后端数据库对数据进行只读查询•单体式应用很少改变其数据库
第一部分中提到了一个直接连接到单体应用数据库的解决方案。在这个示例中,我们需要采纳这样的方案,因为数据库中的数据将为新的Orders服务所用,同时我们还要将这个新服务从单体应用中分离出来。此外,我们希望引入这个新服务之后,能负载流量,并与单体应用中的内容具有一致的视图;例如,我们将在一段时间内同时运行两种服务。注意,这项操作将直击分解动作的核心:我们不可能就这样神奇地调用新的微服务,使它在不影响当前负载的情况下,准确地封装预订或订购的所有逻辑,这是不现实的。
使用被单体应用公开的现有API
创建一个新API,专门用于访问单体应用的数据库;在我们需要数据的时候,随时调用
从单体应用到新的微服务,做一个提取转换加载(ETL),这样我们就有了数据
使用现有的API
如果这么做,一定要深思用法。通常情况下,现有的API都是相当粗粒度的,无法适用于低级别的使用,并且还可能需要做大量的调整才能让其适应新服务中的数据模型。在这个新的Orders服务中,每项对新服务输入调用,都需要查询(这里可能是多个端点的)遗留API或是单体应用API,还要根据你自己的喜好再去处理响应值。这没有什么本质上的错误,除非你打算走捷径,但走捷径会让单体应用、遗留的API或数据模型严重影响到新服务的数据模型。虽然在我的这个示例中,两个数据模型一开始可能是类似的,但我们希望使用DDD来进行快速迭代,并获得正确的域模型(domainmodel),而不仅仅是获得规范化的数据模型。
创建新的低级别API
如果现有的单体应用没有API或API粒度太粗,又或者你不想还继续用它,那么就可以创建一个新的低级别API,使其直接连接到单体应用的数据库,并以新Orders服务所需要的等级来公开数据。这倒也是一个可以接受的解决方案。另一方面,我的经验是,新的Orders服务不会对这个低级别接口写入大量的查询或API调用,而会在内存连接中执行响应值,这类似于此前的做法。这就像是在执行一个数据库。同样,从本质上讲,这没什么错,但这需要为Orders服务编写大量的冗余代码(大量重复,只有少许不同),这些代码往往只是一些临时的、过渡性的方案。
从单体应用到新服务,做一个提取转换加载(ETL)
某种程度上来说,我们可能确实需要这么做。但在研究新服务的域模型时,我们可能并不想再去处理旧的单体应用。此外,我们又想让新服务与单体应用同时运行,二者都能负载流量。如果采纳了ETL的方法,那么我们需要想办法来维持Orders服务的状态更新,因为这些内容可能无法及时同步。这最终会成为大麻烦。
关于TeiidSpringBoot的介绍
再次重申:我们必须专注于服务的域模型,但最初支持域模型的数据仍将存在于单体应用或后端数据库中。我们是否可以将单体应用的数据模型结构与所期望的域模型结合,并且去掉与数据结合有关的冗余代码?
TeiidSpringBoot[29]能让我们专注于域模型,用JPA@entity为模型创建注解,这一点与其他模型一样,同时,它还能把模型映射到我们的新数据库中,以及虚拟地映射单体架构的数据库。要开始使用teiid-spring-boot,你只需要导入以下依赖项:
/groupIdartifactIdteiid-spring-boot-starter//version/depency
这是一个启动项目,它会连接到Spring的自动配置,并尝试设置我们的虚拟数据库(由单体应用的数据库和本服务拥有的真实物理数据库提供支持)。
接下来我们需要为每个后端定义SpringBoot中的数据源。在这个示例中,我用了两个MySQL数据库,但这只是一个细节。我们不仅仅限于两个相同的数据源,也不应该局限于关系数据库管理系统(RDBMs)。以下是举例:
=jdbc:mysql://localhost:3306/
ticketmonster?useSSL=====jdbc:mysql://localhost:3306/
orders?useSSL====
下面开始配置teiid-spring-boot,来扫描我们的域模型,使其虚拟映射到单体应用。在应用属性中,我们添加如下内容:
=
TeiidSpringBoot允许我们将映射指定为@entity定义上的注释。下面是一个举例(github上可以参见域对象的完整实现和完整实施[30]):
@SelectQuery(",,,_of_rows,_capacity,venue_id,__id=;")@Entity@Table(name="section",uniqueConstraints=@UniqueConstraint
(columnNames={"name","venue_id"}))publicclassSectionimplementsSerializable{@Id@GeneratedValue(strategy=IDENTITY)privateLongid;@NotEmptyprivateStringname;@NotEmptyprivateStringdescription;@Not@EmbeddedprivateVenueIdvenueId;@Column(name="number_of_rows")privateintnumberOfRows;@Column(name="row_capacity")privateintrowCapacity;在上面的例子中,我们使用@SelectQuery来定义遗留数据源(legacyDS.*)和域模型之间的映射。需要注意,通常这些映射可能存在大量的JOIN操作,以便为模型获取正确的数据;所以最好在一个RESTAPI的注解中只写一次JOIN,因为该注释在处理这些数据转换的时候会尝试编写大量的冗余代码(不仅仅是查询,还包括对我们预期域模型的实际映射)。在上述情况下,只需要从单体应用的数据库映射到域模型就行了,但是如果我们要在自己的数据库中进行merge操作呢?可以这样做(完整实施参见[31]):
@SelectQuery("SELECTid,CAST(priceASdouble),number,rowNumberASrow_number,section_id,ticketCategory_idASticket_category_id,tickets_idASbooking_"+"UNIONALLSELECTid,price,number,row_number,
section_id,ticket_category_id,booking_")请注意,在这里,我们用关键词UNIONALL将单体应用数据库和本地Orders数据库的两个视图结合起来。

那么Upgrade和Insert的问题呢?
例如,我们的Orders服务应当存储Orders或booking。可以在整个bookingDDD中添加@InsertQuery注释,像这样:
@InsertQuery("FOREACHROW\n"+"BEGINATOMIC\n"+"(id,performance_id,
performance_name,cancellation_code,created_on,contact_email)
values(,_id,_name,
_code,_on,_email);\n"+"END")想要获取其余的teiid-spring-boot注释,可参见文档[32]。
可见,当我们保留一个新的booking(如JPA、spring数据等等),虚拟数据库知道将其存储到自身的Orders数据库中。如果你更倾向于使用SpringData,那么你仍然可以充分利用teiid-spring-boot。以下是另一个teiid-spring-boot示例[33]:
publicinterfaceCustomerRepositoryextsCrudRepository
Customer,Long{@Query("=:ssn")StreamCustomerfindBySSNReturnStream(@Param("ssn")Stringssn);}
如果我们选择好一个合适teiid-spring-boot映射注释,那么这个spring-data存储库就能够正确理解虚拟数据库层,并能按照预期来处理域模型。
再次强调:这是微服务分解初始步骤中的暂时性解决方案而非最终方案。我们还是需要在运行示例中对其进行迭代。我们正在试图通过手动的方式来减少做映射或转译时可能产生的样板代码和麻烦。
同样,如果你仍有意要为访问单体应用数据库的低级别数据建立一个简单的API,那么teiid-spring-boot也仍然会对你很有帮助。你可以很快地发布这类API,该API中没有使用通过teiid-spring-boot生成的odata集成。浏览odata模块[34]可获取更多内容(注意,我们还在持续的编写该项目的文档)
在分解的这个节点上,理应有一个配合着合适的API,域模型和连接到我们自身数据库的Orders服务实施,并暂时创建一个虚拟映射到我们的单体数据库,以便在域模型中使用该数据库。接下来,我们需要将它部署到生产中,进行灰度上线。
3发送shadowtraffic到新的微服务(darklaunch)
回顾下注意事项
将新订单服务引入代码路径有风险
要以可控的方式将流量发送给新服务
希望流量能被引到新服务以及旧代码路径
要测量和监控新服务的影响
要设法标记“合成(synthetic)”事物,以防发生比较头疼的业务一致性问题
希望新功能部署到特定的群组或用户
接着我们在本主题第一部分中提到的内容,我们将通过修改单体应用来调用新的Orders服务。这会用到MichaelFeather书中[35]的一些技术,来改造或者扩展单体应用中的现有逻辑,从而调用新服务。例如,我们的单体应用在实现createBookings时是这样的:
@POST@Consumes(_JSON)publicResponsecreateBooking(BookingRequestbookingRequest){try{//identifytheticketpricecategoriesinthisrequestSetLongpriceCategoryIds=bookingRequest.
getUniquePriceCategoryIds;//loadtheentitiesthatmakeupthisbooking'srelationshipsPerformanceperformance=getEntityManager.
find(,);//Aswecanhaveamixoftickettypesinabooking,
weneedtoloadallofthemthatarerelevant,//idMapLong,TicketPriceticketPricesById=
loadTicketPrices(priceCategoryIds);//Now,starttocreatethebookingfromtheposteddata//Setthesimplestufffirst!Bookingbooking=newBooking;();(performance);("abc");//Now,weiterateovereachticketthatwasrequested,
andorganizethembysectionandcategory//wewanttoallocateticketrequeststhatbelongto
thesamesectioncontiguouslyMapSection,MapTicketCategory,TicketRequest
ticketRequestsPerSection=newTreeMapSection,,
TicketRequest();for(TicketRequestticketRequest:
){finalTicketPriceticketPrice=
();if(!
()){(,
newHashMapTicketCategory,TicketRequest);}().put((ticketRequest.
getTicketPrice).getTicketCategory,ticketRequest);}就像其他任何单体应用一样,这里只展现了一小部分代码,还有更多没罗列出来,他们的内容又长又复杂,想要理解透彻着实不易。所以我们会将把它们转换成这样:
@POST@Consumes(_JSON)publicResponsecreateBooking(BookingRequestbookingRequest){Responseresponse=;if(("orders-internal")){response=createBookingInternal(bookingRequest);}if(("orders-service")){if(("orders-internal")){createSyntheticBookingOrdersService(bookingRequest);}else{response=createBookingOrdersService(bookingRequest);}}returnresponse;}转换以后的代码,内容更少、更有条理且更容易执行。那么究竟发生了什么?()又是什么?
这里要遵循的一个关键点是,单体应用的变更越少越好;理想情况下,我们要进行单元、组件、集成或系统测试来帮忙验证这些更改是否会对其他内容产生负面影响。如果无法做到,那我们就需要有策略地进行重构,使其能够进行测试。
在已经更改的部分中,现有的调用流最好保持原样:于是,我们将早前的实现移动到一个名为createBookingInternal的方法中,并保持原样。不过,我们还运用了一个新的手段来调用Orders服务的新代码路径。并将启用一个特性标志库[36],它能实现以下功能:
用于实现订单的全时运行/配置控件
禁用新功能
同时启用新功能和旧功能
完全切换到新功能
删除switchall功能
这里用的是FeatureFlags4Java(FF4j)[37],当然还有其他编程语言的替代方案,包括像LaunchDarkly这样的托管SaaS提供商[38]。当然,你也可以选择自己来编写框架,不过现有的这些项目功能都是现成的,完全可以直接拿来用。这和Facebook(和其它)的控制框架[39]非常相似。回顾部署和发布间的差异请参阅此处[40]。
要使用FF4j,依赖项需要被添加到中
/groupIdartifactIdff4j-core/artifactIdversion${}/version/depency然后,我们可以在文件中阐述特性,并将其进行组合等。更详细的关于复杂特性或特性分组的信息,请参阅ff4j文档[41]:
featuresfeatureuid="orders-internal"enable="true"
description="Continuewithlegacyordersimplementation"/featureuid="orders-service"enable="false"
description="Callnewordersmicroservice"//features
然后,我们可以将一个FF4j对象实例化[42],并用它来测试这些特性是否已经在代码中启用:
FF4jff4j=newFF4j("");if(("special-feature")){doSpecialFeature;}“即开即用(outofthebox)”的实现采用了配置文件来指定特性。随后,就可以在运行时进行特性切换(见下文),但在继续下一步之前,我想指出的是,这些特性以及它们各自的状态,比如启用或禁用状态下,都应该由重要(non-trivial)部署中的持久化存储(persistentstore)设备进行备份。请查看ff4j站点上的featurestore文档[43]。
在运行时,我们还希望能配置或改变特性在运行时的状态。FF4j有一个网页控制台可以用来部署[44],从而查看或改变应用程序中的特性状态:
4指定服务契约
这时候,我们可能应该将单体应用连接到新的Orders服务,用于预订和下单流程。现在对于单体应用来说,是一个明确其在调用Orders服务时在契约或数据方面要求的好时机。当然,Orders服务是一个独立、自治的服务,它承诺可以提供一些特定的功能或SLA、SLO等[45],但当我们开始构建分布式系统时,有必要了解一下有关服务交互的假设,并理清楚。
上图来自Pact文档[49]
让我们再来看一个后端服务的示例[50]。我们将为back-v2应用程序创建一个用户契约规则,这个规则概述了服务提供商(Orders服务)的期望。当我们将POSTHTTP请求发布到/rest/bookings时,我们可以通过以下方式强调一下期望。
@Pact(provider="orders_service",consumer="test_synthetic_order")publicRequestResponsePactcreateFragment(PactDslWithProviderbuilder){RequestResponsePactpact=("availableshows").uponReceiving("bookingrequest").path("/rest/bookings").matchHeader("Content-Type","application/json").method("POST").body(bookingRequestBody).(syntheticBookingResponseBody).status(200).toPact;returnpact;}当调用提供商提供的服务并将其传入一个特定主体时,会有一个HTTP200以及与契约匹配的响应值。我们来看一下。首先,先来看看如何指定预订请求主体:
privateDslPartbookingRequestBody{PactDslJsonBodybody=newPactDslJsonBody;("performance",1).booleanType("synthetic",true).stringType("email","foo@").minArrayLike("ticketRequests",1).integerType("ticketPrice",1).integerType("quantity").;returnbody;}我们可以创建PactDslJsonBody代码片段,并且使用“通配符”或“在此字段中传入任何内容”的语法。例如,我们用("attr_name",default_value)来规定“将存在一个名为X、并且有默认值的属性”。如果去掉默认值参数,那么该值实际上可以是任何值。在此代码片段中,我们只规定请求的结构。注意,在此我们指定了一个合成(synthetic)属性。并且对于每个属性为true的请求,均会有一个具有特定结构的响应值。
在这里,我们声明用户契约(响应值):
privateDslPartsyntheticBookingResponseBody{PactDslJsonBodybody=newPactDslJsonBody;("synthetic",true);returnbody;}这是一个非常简单的例子:对于这个测试,我们所期望的是,该响应值将会有一个属性为:“synthetic:true”。这很重要,因为当发送合成(synthetic)预订时,我们希望确保Orders服务确认这个预订确实被当做一个合成(synthetic)请求进行处理。如果这个测试成功运行,我们将在目标构建目录中生成这个Pact契约。(在本文例子中,它会出现./target/pacts中。)
{"provider":{"name":"orders_service"},"consumer":{"name":"test_synthetic_order"},"interactions":[{"description":"bookingrequest","request":{"method":"POST","path":"/rest/bookings","headers":{"Content-Type":"application/json"},"body":{"synthetic":true,"performance":1,"ticketRequests":[{"quantity":100,"ticketPrice":1}],"email":"foo@"},"matchingRules":{"header":{"Content-Type":{"matchers":[{"match":"regex","regex":"application/json"}],"combine":"AND"}},"body":{"$.performance":{"matchers":[{"match":"integer"}],"combine":"AND"},"$.synthetic":{"matchers":[{"match":"type"}],"combine":"AND"},"$.email":{"matchers":[{"match":"type"}],"combine":"AND"},"$.ticketRequests":{"matchers":[{"match":"type","min":1}],"combine":"AND"},"$.ticketRequests[*].ticketPrice":{"matchers":[{"match":"integer"}],"combine":"AND"},"$.ticketRequests[*].quantity":{"matchers":[{"match":"integer"}],"combine":"AND"}},"path":{}},"generators":{"body":{"$.ticketRequests[*].quantity":{"type":"RandomInt","min":0,"max":2147483647}}}},"response":{"status":200,"headers":{"Content-Type":"application/json;charset=UTF-8"},"body":{"synthetic":true},"matchingRules":{"body":{"$.synthetic":{"matchers":[{"match":"type"}],"combine":"AND"}}}},"providerStates":[{"name":"availableshows"}]}],"metadata":{"pact-specification":{"version":"3.0.0"},"pact-jvm":{"version":""}}}此处,可以将契约放入Git[57]、ContractBroker[58]或共享文件系统[59]中。在供应端(Orders服务)上,我们可以创建一个组件测试,来确保提供商提供的服务实际上满足了用户契约中的期望。需要注意的是,用户契约可以有多个,所有这些契约都是可以测试的(尤其当我们对供应商提供的服务进行更改时,可以通过影响测试来了解可能会受到影响的下游用户)
@RunWith()@Provider("orders_service")@PactFolder("pact/")publicclassConsumerContractTest{privatestaticConfigurableApplicationContextapplicationContext;@TestTargetpublicfinalTargettarget=newHttpTarget(8080);@BeforeClasspublicstaticvoidstartSpring{applicationContext=();}@State("availableshows")publicvoidtestDefaultState{("hi");}}请注意在这个简单的示范中,我们将从附属./pacts下的文件系统中的一个文件夹中提取契约。
一旦采取了用户驱动契约测试,我们就能更自如地对服务作出变更。有关此问题的工作示例,请参见back-v2服务[60]以及供应商Orders服务[61]的示例。
5金丝雀测试或滚动发布新的微服务
回顾下注意事项
确定群组,并将实时事务流量发送给新的微服务
直接连接数据库仍然是需要的,因为在此期间,事务仍会从两条代码路径通过
将所有流量转到微服务后,就该放弃旧功能了
请注意,在将实时流量发送给微服务后,回滚到旧代码路径将遇到困难,需要协调
该场景另外一个重要部分是,我们需要通过具有特征标志的新部署来发送一小部分流量。我们可以使用Istio来精确地控制被调用的后端。例如,我们已经部署了back-v1,其已完全发布,并接受生产负载。当我们部署back-v2,且其具有控制新代码路径的特性标志时,我们可以使用Istio来进行金丝雀发布,这与此前文章中的做法类似。从只发送1%的流量开始,然后缓慢增加(5%,25%等),发送的同时注意时刻观察效果。我们还可以将这些特性进行切换,以便同时启用旧代码路径和新代码路径。这是一项非常强大的技术,能帮助我们大大降低微服务架构改变和迁移时所带来的风险。下面是一个istioroute-rule的示例:
apiVersion:/v1alpha2kind:RouteRulemetadata:name:back-v2spec:destination:name:backprecedence:20route:-labels:version:v1weight:99-labels:version:v2weight:1
一些需要注意的事项:到了现在这一步,我们可能会使用新的Orders服务来同时启用旧代码路径和新代码路径,且新Orders服务会执行合成事务。到目前为止,所描述的金丝雀将适用于1%的任何流量。如果仅向内部用户或一小部分外部用户发布,并实际通过实时Orders服务(即非模拟流量)对它们进行发布,那么这可能是有用的。通过将基于用户的修改路径和将用户分组到队列的FF4j配置相结合,我们就可以启用新Orders服务的完整代码路径(包括实时流量、非合成事务性载荷等)。然而,这一点的关键是,一旦用户已被定向到Orders的实时代码路径,为了方便以后的调用,会一直这样发送。这是因为一旦用新服务进行下单,该Orders将不会出现在单体应用的数据库中。对该用户的所有查询或更新都应该始终通过新的微服务。
此时,我们可以观察流量模式或服务表现,并做出是否增加发布范围的决定。最终,我们的目的是将所有流量发送到新服务上。
如果数据不在单体应用中,该怎么办?你可能会选择什么都不做——新Orders服务现在是订单或预订逻辑加数据的合法所有者。对于这些新Orders,如果觉得有必要在单体应用之间进行集成,你可以选择发布新Orders服务中的事件以及订单详细信息。这样,单体应用也可以捕捉这些事件,并将它们储存在其数据库中。其他服务也可以监听这些事件,并对其作出反应。事件发布机制还是有用的。
参考地址:
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
[10]
[11]
[12]
[13]
[14]
[15]
[16]
[18]
[19]
[20]
[22]
[23]
[24]
[25]
[27]
[28、29]
[30]
[31]
[32]
[33]
[34]
[35]
[36、37]
[40]
[41]
[42]
[43]
[44]
[46]
[47]
[48]
[49]
[50]
[51]
[52]
[53、55]
[60]
[61]