本文获得blog.christianposta授权翻译发表,转载需要注明来自公众号EAWorld。
作者:Christian Posta
译者:海松
原题:Low-risk Monolith to Microservice Evolution Part III
全文7900字,阅读约需要20分钟
在第一部分中,我们通过一个具体的示例,介绍了如何在不影响系统访问和业务价值的前提下将微服务引入架构。在第二部分中,我们列举了一些与上述架构战略及目标相一致的技术。在本文中,我们将继续第二部分的解决方案,重点讨论如何添加可能需要与单体架构共享数据(至少在初始阶段)的新服务,然后再引入一些更为复杂的部署场景。我们还会探索如何用Arquilli-Analgeron[1]来进行用户契约测试,以及如何使用它来处理我们服务架构中的API更改。关注我的推特:@christianposta或访问http://blog.christianposta.com,可获取最多内容。
也可点击链接重温本文的第一部分和第二部分。
一、技术
本主题第二部分、第三部分和第四部分中涉及到的技术如下,这些技术在我们的实践过程中将具备一定的指导作用:
开发人员服务框架(Spring Boot[2],WildFly[3],WildFly Swarm[4])
API设计(APICur.io[5])
数据框架(Spring Boot Teiid[6],Debezium.io[7])
集成工具(Apache Camel[8])
服务网格(Istio Service Mesh[9])
数据库迁移工具(Liquibase[10])
Dark launch / feature flag框架(FF4J[11])
部署/CI-CD平台(Kubernetes[12]/OpenShift[13])
Kubernetes开发工具(Fabric8.io[14])
测试工具(Arquillian[15],Pact[16]/Arquillian Algeron[17],Hoverfly[18],Spring-Boot Test[19],RestAssured[20],Arquillian Cube[21])
如果你想一起动手实践,那么可以和我一起使用http://developers.redhat.com上的TicketMonster教程作为示例项目,我借用了该教程用以演示如何完成从单体应用到微服务的演变。你还可以在github上找到相关的代码和文档(文档还在编写中):https://github.com/ticket-monster-msa/monolith
在第二部分中,我们开始添加一个将要从单体应用中剥离出来的微服务(Orders/Booking)。我们借助Hoverfly模拟探索合适的API设计来开始这一步工作。
二、将API与实现进行对接
回顾下注意事项
在定义上,被抽取或新建的服务的数据模型和单体应用的数据模型紧耦合
单体应用很可能没有提供在合适层级获取数据的API
即使我们获取了数据,也需要大量的代码样例来进行数据转换
我们可以临时性的直接访问后端数据库对数据进行只读查询• 单体式应用很少改变其数据库
第一部分中提到了一个直接连接到单体应用数据库的解决方案。在这个示例中,我们需要采纳这样的方案,因为数据库中的数据将为新的Orders服务所用,同时我们还要将这个新服务从单体应用中分离出来。此外,我们希望引入这个新服务之后,能负载流量,并与单体应用中的内容具有一致的视图;例如,我们将在一段时间内同时运行两种服务。注意,这项操作将直击分解动作的核心:我们不可能就这样神奇地调用新的微服务,使它在不影响当前负载的情况下,准确地封装预订或订购的所有逻辑,这是不现实的。
那如果我们不想连接单体应用的数据库,还能有什么选择?我可以枚举一些…当然如果你还有其他建议,欢迎随时评论或推我:
使用被单体应用公开的现有API
创建一个新API,专门用于访问单体应用的数据库;在我们需要数据的时候,随时调用
从单体应用到新的微服务,做一个提取转换加载(ETL),这样我们就有了数据
使用现有的API
如果这么做,一定要深思用法。通常情况下,现有的API都是相当粗粒度的,无法适用于低级别的使用,并且还可能需要做大量的调整才能让其适应新服务中的数据模型。在这个新的Orders服务中,每项对新服务输入调用,都需要查询(这里可能是多个端点的)遗留API或是单体应用API,还要根据你自己的喜好再去处理响应值。这没有什么本质上的错误,除非你打算走捷径,但走捷径会让单体应用、遗留的API或数据模型严重影响到新服务的数据模型。虽然在我的这个示例中,两个数据模型一开始可能是类似的,但我们希望使用DDD来进行快速迭代,并获得正确的域模型(domain model ),而不仅仅是获得规范化的数据模型。
创建新的低级别 API
如果现有的单体应用没有API或API粒度太粗,又或者你不想还继续用它,那么就可以创建一个新的低级别API,使其直接连接到单体应用的数据库,并以新Orders服务所需要的等级来公开数据。这倒也是一个可以接受的解决方案。另一方面,我的经验是,新的Orders服务不会对这个低级别接口写入大量的查询或API调用,而会在内存连接中执行响应值,这类似于此前的做法。这就像是在执行一个数据库。同样,从本质上讲,这没什么错,但这需要为Orders服务编写大量的冗余代码(大量重复,只有少许不同),这些代码往往只是一些临时的、过渡性的方案。
从单体应用到新服务,做一个提取转换加载(ETL)
某种程度上来说,我们可能确实需要这么做。但在研究新服务的域模型时,我们可能并不想再去处理旧的单体应用。此外,我们又想让新服务与单体应用同时运行,二者都能负载流量。如果采纳了ETL的方法,那么我们需要想办法来维持Orders服务的状态更新,因为这些内容可能无法及时同步。这最终会成为大麻烦。
作为新Order服务的开发人员,我觉得从域模型(注意:我不是指数据模型,二者之间是有区别的)的角度来考虑问题才对新服务有意义。外部实现的影响应该尽可能去消除,因为这可能会影响域模型。区别在于:数据模型显示了系统中的静态数据如何关联,这可能为如何在持久层中储存数据提供了依据。域模型则用于描述域的解析空间的行为,更多地倾向于关注用例或事务行为。例如,我们用来识别问题的概念或模型就属于域模型。DDD大师Vaughn Vernon[22]写了一系列文章[23],更详细地讨论了这种区别。
我的解决方案是在Ticket Monster Orders[24]中引入了一个开源项目Teiid[25],它能帮忙减少甚至消除往理想域模型添加数据处理模型的冗余代码。Teiid历来是一个数据联合软件[26],它能够获取不同的数据来源(如关系数据库、非关系型数据库、无格式文件等),并将其作为单个虚拟化视图进行呈现。通常,数据分析人员会使用Teiid来聚合数据,用于汇报等。但是我们更感兴趣的是开发人员如何使用它解决上述问题。幸运的是,来自Teiid社区的人,特别是Ramesh Reddy[27],为Teiid和Spring Boot [28]创建了一些不错的扩展程序来帮助消除在解决问题过程中产生的冗余代码。
关于Teiid Spring Boot的介绍
再次重申:我们必须专注于服务的域模型,但最初支持域模型的数据仍将存在于单体应用或后端数据库中。我们是否可以将单体应用的数据模型结构与所期望的域模型结合,并且去掉与数据结合有关的冗余代码?
Teiid Spring Boot[29]能让我们专注于域模型,用JPA @entity 为模型创建注解,这一点与其他模型一样,同时,它还能把模型映射到我们的新数据库中,以及虚拟地映射单体架构的数据库。要开始使用teiid - spring - boot,你只需要导入以下依赖项:
org.teiid.spring teiid-spring-boot-starter 1.0.0-SNAPSHOT
<groupId>org.teiid.springgroupId>
<artifactId>teiid-spring-boot-starterartifactId>
<version>1.0.0-SNAPSHOTversion>
dependency>
(左右滑动可查看全部代码,下同)
这是一个启动项目,它会连接到Spring的自动配置,并尝试设置我们的虚拟数据库(由单体应用的数据库和本服务拥有的真实物理数据库提供支持)。
接下来我们需要为每个后端定义Spring Boot中的数据源。在这个示例中,我用了两个MySQL数据库,但这只是一个细节。我们不仅仅限于两个相同的数据源,也不应该局限于关系数据库管理系统(RDBMs)。以下是举例:
spring.datasource.legacyDS.url=jdbc:mysql://localhost:3306/ticketmonster?useSSL=falsespring.datasource.legacyDS.username=ticketspring.datasource.legacyDS.password=monsterspring.datasource.legacyDS.driverClassName=com.mysql.jdbc.Driverspring.datasource.ordersDS.url=jdbc:mysql://localhost:3306/orders?useSSL=falsespring.datasource.ordersDS.username=ticketspring.datasource.ordersDS.password=monsterspring.datasource.ordersDS.driverClassName=com.mysql.jdbc.Drivermysql://localhost:3306/ticketmonster?useSSL=false
spring.datasource.legacyDS.username=ticket
spring.datasource.legacyDS.password=monster
spring.datasource.legacyDS.driverClassName=com.mysql.jdbc.Driver
spring.datasource.ordersDS.url=jdbc:mysql://localhost:3306/orders?useSSL=false
spring.datasource.ordersDS.username=ticket
spring.datasource.ordersDS.password=monster
spring.datasource.ordersDS.driverClassName=com.mysql.jdbc.Driver
下面开始配置teiid - spring -boot,来扫描我们的域模型,使其虚拟映射到单体应用。在应用属性中,我们添加如下内容:
spring.teiid.model.package=org.ticketmonster.orders.domain
Teiid Spring Boot允许我们将映射指定为@entity定义上的注释。下面是一个举例(github上可以参见域对象的完整实现和完整实施[30]) :
@SelectQuery("SELECT s.id, s.description, s.name, s.numberOfRows AS number_of_rows, s.rowCapacity AS row_capacity, venue_id, v.name AS venue_name FROM legacyDS.Section s JOIN legacyDS.Venue v ON s.venue_id=v.id;")@Entity@Table(name = "section", uniqueConstraints=@UniqueConstraint(columnNames={"name", "venue_id"}))public class Section implements Serializable { @Id @GeneratedValue(strategy = IDENTITY) private Long id; @NotEmpty private String name; @NotEmpty private String description; @NotNull @Embedded private VenueId venueId; @Column(name = "number_of_rows") private int numberOfRows; @Column(name = "row_capacity") private int rowCapacity;
@Entity
@Table(name = "section", uniqueConstraints=@UniqueConstraint(columnNames={"name", "venue_id"}))
public class Section implements Serializable {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@NotEmpty
private String name;
@NotEmpty
private String description;
@NotNull
@Embedded
private VenueId venueId;
@Column(name = "number_of_rows")
private int numberOfRows;
@Column(name = "row_capacity")
private int rowCapacity;
在上面的例子中,我们使用 @SelectQuery 来定义遗留数据源( legacyDS.*)和域模型之间的映射。需要注意,通常这些映射可能存在大量的JOIN操作,以便为模型获取正确的数据;所以最好在一个REST API 的注解中只写一次JOIN,因为该注释在处理这些数据转换的时候会尝试编写大量的冗余代码(不仅仅是查询,还包括对我们预期域模型的实际映射)。在上述情况下,只需要从单体应用的数据库映射到域模型就行了,但是如果我们要在自己的数据库中进行merge操作呢?可以这样做(完整实施参见ticket.java[31]) :
@SelectQuery("SELECT id, CAST(price AS double), number, rowNumber AS row_number, section_id, ticketCategory_id AS ticket_category_id, tickets_id AS booking_id FROM legacyDS.Ticket " +"UNION ALL SELECT id, price, number, row_number, section_id, ticket_category_id, booking_id FROM ordersDS.ticket")id, CAST(price AS double), number, rowNumber
AS row_number, section_id, ticketCategory_id
AS ticket_category_id, tickets_id AS booking_id
FROM legacyDS.Ticket " +
"UNION ALL SELECT id, price, number, row_number, section_id, ticket_category_id,
booking_id FROM ordersDS.ticket")
请注意,在这里,我们用关键词UNION ALL将单体应用数据库和本地Orders数据库的两个视图结合起来。
那么Upgrade和Insert的问题呢?
例如,我们的Orders服务应当存储Orders或booking。可以在整个booking DDD中添加@ InsertQuery注释,像这样:
@InsertQuery("FOR EACH ROW \n"+ "BEGIN ATOMIC \n" + "INSERT INTO ordersDS.booking (id, performance_id, performance_name, cancellation_code, created_on, contact_email ) values (NEW.id, NEW.performance_id, NEW.performance_name, NEW.cancellation_code, NEW.created_on, NEW.contact_email);\n" + "END")BEGIN ATOMIC \n" +
"INSERT INTO ordersDS.booking (id, performance_id, performance_name, cancellation_code, created_on, contact_email ) values (NEW.id, NEW.performance_id, NEW.performance_name, NEW.cancellation_code, NEW.created_on, NEW.contact_email);\n" +
"END")
想要获取其余的teiid - spring -boot注释,可参见文档[32]。
可见,当我们保留一个新的booking(如JPA、spring数据等等),虚拟数据库知道将其存储到自身的Orders数据库中。如果你更倾向于使用Spring Data,那么你仍然可以充分利用teiid - spring -boot。以下是另一个teiid - spring - boot示例[33]:
public interface CustomerRepository extends CrudRepository {@Query("select c from Customer c where c.ssn = :ssn")Stream findBySSNReturnStream(@Param("ssn") String ssn);}interface CustomerRepository extends CrudRepository<Customer, Long> {
@Query("select c from Customer c where c.ssn = :ssn")
Stream findBySSNReturnStream(@Param("ssn") String ssn) ;
}
如果我们选择好一个合适teiid - spring - boot映射注释,那么这个spring -data存储库就能够正确理解虚拟数据库层,并能按照预期来处理域模型。
再次强调:这是微服务分解初始步骤中的暂时性解决方案而非最终方案。我们还是需要在运行示例中对其进行迭代。我们正在试图通过手动的方式来减少做映射或转译时可能产生的样板代码和麻烦。
同样,如果你仍有意要为访问单体应用数据库的低级别数据建立一个简单的API,那么teiid - spring -boot也仍然会对你很有帮助。你可以很快地发布这类API,该API中没有使用通过teiid - spring -boot生成的odata集成。浏览odata模块[34]可获取更多内容(注意,我们还在持续的编写该项目的文档)
在分解的这个节点上,理应有一个配合着合适的API,域模型和连接到我们自身数据库的Orders服务实施,并暂时创建一个虚拟映射到我们的单体数据库,以便在域模型中使用该数据库。接下来,我们需要将它部署到生产中,进行灰度上线。
三、发送shadow traffic到
新的微服务(dark launch)
回顾下注意事项
将新订单服务引入代码路径有风险
要以可控的方式将流量发送给新服务
希望流量能被引到新服务以及旧代码路径
要测量和监控新服务的影响
要设法标记“合成(synthetic)”事物,以防发生比较头疼的业务一致性问题
希望新功能部署到特定的群组或用户
接着我们在本主题第一部分中提到的内容,我们将通过修改单体应用来调用新的Orders服务。这会用到Michael Feather书中[35]的一些技术,来改造或者扩展单体应用中的现有逻辑,从而调用新服务。例如,我们的单体应用在实现createBookings时是这样的:
@POST@Consumes(MediaType.APPLICATION_JSON)public Response createBooking(BookingRequest bookingRequest) {try { // identify the ticket price categories in this request Set priceCategoryIds = bookingRequest.getUniquePriceCategoryIds(); // load the entities that make up this booking's relationships Performance performance = getEntityManager().find(Performance.class, bookingRequest.getPerformance()); // As we can have a mix of ticket types in a booking, we need to load all of them that are relevant, // id Map ticketPricesById = loadTicketPrices(priceCategoryIds); // Now, start to create the booking from the posted data // Set the simple stuff first! Booking booking = new Booking(); booking.setContactEmail(bookingRequest.getEmail()); booking.setPerformance(performance); booking.setCancellationCode("abc"); // Now, we iterate over each ticket that was requested, and organize them by section and category // we want to allocate ticket requests that belong to the same section contiguously Map> ticketRequestsPerSection = new TreeMap>(SectionComparator.instance()); for (TicketRequest ticketRequest : bookingRequest.getTicketRequests()) { final TicketPrice ticketPrice = ticketPricesById.get(ticketRequest.getTicketPrice()); if (!ticketRequestsPerSection.containsKey(ticketPrice.getSection())) { ticketRequestsPerSection .put(ticketPrice.getSection(), new HashMap()); } ticketRequestsPerSection.get(ticketPrice.getSection()).put( ticketPricesById.get(ticketRequest.getTicketPrice()).getTicketCategory(), ticketRequest); }
@Consumes(MediaType.APPLICATION_JSON)
public Response createBooking(BookingRequest bookingRequest) {
try {
// identify the ticket price categories in this request
Set<Long> priceCategoryIds = bookingRequest.getUniquePriceCategoryIds();
// load the entities that make up this booking's relationships
Performance performance = getEntityManager().find(Performance.class, bookingRequest.getPerformance());
// As we can have a mix of ticket types in a booking, we need to load all of them that are relevant,
// id
Map<Long, TicketPrice> ticketPricesById = loadTicketPrices(priceCategoryIds);
// Now, start to create the booking from the posted data
// Set the simple stuff first!
Booking booking = new Booking();
booking.setContactEmail(bookingRequest.getEmail());
booking.setPerformance(performance);
booking.setCancellationCode("abc");
// Now, we iterate over each ticket that was requested, and organize them by section and category
// we want to allocate ticket requests that belong to the same section contiguously
Map> ticketRequestsPerSection
= new TreeMap>(SectionComparator.instance());
for (TicketRequest ticketRequest : bookingRequest.getTicketRequests()) {
final TicketPrice ticketPrice = ticketPricesById.get(ticketRequest.getTicketPrice());
if (!ticketRequestsPerSection.containsKey(ticketPrice.getSection())) {
ticketRequestsPerSection
.put(ticketPrice.getSection(), new HashMap());
}
ticketRequestsPerSection.get(ticketPrice.getSection()).put(
ticketPricesById.get(ticketRequest.getTicketPrice()).getTicketCategory(), ticketRequest);
}
就像其他任何单体应用一样,这里只展现了一小部分代码,还有更多没罗列出来,他们的内容又长又复杂,想要理解透彻着实不易。所以我们会将把它们转换成这样:
@POST@Consumes(MediaType.APPLICATION_JSON)public Response createBooking(BookingRequest bookingRequest) {Response response = null;if (ff.check("orders-internal")) { response = createBookingInternal(bookingRequest);}if (ff.check("orders-service")) { if (ff.check("orders-internal")) { createSyntheticBookingOrdersService(bookingRequest); } else { response = createBookingOrdersService(bookingRequest); }}return response;}
@Consumes(MediaType.APPLICATION_JSON)
public Response createBooking(BookingRequest bookingRequest) {
Response response = null;
if (ff.check("orders-internal")) {
response = createBookingInternal(bookingRequest);
}
if (ff.check("orders-service")) {
if (ff.check("orders-internal")) {
createSyntheticBookingOrdersService(bookingRequest);
}
else {
response = createBookingOrdersService(bookingRequest);
}
}
return response;
}
转换以后的代码,内容更少、更有条理且更容易执行。那么究竟发生了什么? ff.check(...) 又是什么?
这里要遵循的一个关键点是,单体应用的变更越少越好;理想情况下,我们要进行单元、组件、集成或系统测试来帮忙验证这些更改是否会对其他内容产生负面影响。如果无法做到,那我们就需要有策略地进行重构,使其能够进行测试。
在已经更改的部分中,现有的调用流最好保持原样:于是,我们将早前的实现移动到一个名为 createBookingInternal的方法中,并保持原样。不过,我们还运用了一个新的手段来调用Orders服务的新代码路径。并将启用一个特性标志库[36],它能实现以下功能:
用于实现订单的全时运行/配置控件
禁用新功能
同时启用新功能和旧功能
完全切换到新功能
删除switch all功能
这里用的是Feature Flags 4 Java (FF4j)[37],当然还有其他编程语言的替代方案,包括像Launch Darkly这样的托管SaaS提供商[38]。当然,你也可以选择自己来编写框架,不过现有的这些项目功能都是现成的,完全可以直接拿来用。这和Facebook(和其它)的控制框架[39]非常相似。回顾部署和发布间的差异请参阅此处[40]。
要使用FF4j,依赖项需要被添加到pom.xml中
org.ff4j ff4j-core ${ffj4.version}
<groupId>org.ff4jgroupId>
<artifactId>ff4j-coreartifactId>
<version>${ffj4.version}version>
dependency>
然后,我们可以在ff4j.xml文件中阐述特性,并将其进行组合等。更详细的关于复杂特性或特性分组的信息,请参阅ff4j文档[41]:
<feature uid="orders-internal" enable="true" description="Continue with legacy orders implementation" />
<feature uid="orders-service" enable="false" description="Call new orders microservice" />
features>
然后,我们可以将一个FF4j对象实例化[42],并用它来测试这些特性是否已经在代码中启用:
FF4j ff4j = new FF4j("ff4j.xml");if (ff4j.check("special-feature")){ doSpecialFeature();} "ff4j.xml");
if (ff4j.check("special-feature")){
doSpecialFeature();
}
“即开即用(out of the box )”的实现采用了ff4j.xml配置文件来指定特性。随后,就可以在运行时进行特性切换(见下文),但在继续下一步之前,我想指出的是,这些特性以及它们各自的状态,比如启用或禁用状态下,都应该由重要(non-trivial)部署中的持久化存储(persistent store)设备进行备份。请查看ff4j站点上的featurestore文档[43]。
在运行时,我们还希望能配置或改变特性在运行时的状态。FF4j有一个网页控制台可以用来部署[44],从而查看或改变应用程序中的特性状态:
默认情况下,我们将只启用旧特性来进行部署。也就是说,在默认情况下,代码执行路径和服务表现并没有发生变化。然后,我们可以进行金丝雀部署,并使用特性标志来同时启用旧代码路径和新路径,新路径会调用新的Orders服务。对于某些服务,我们可能不需要太过关注,只需要启用第二个代码路径即可。但是,我们需要通过设置这是一个“测试”或“合成(synthetic)”事务之类的提示,来避免一些会改变状态的事件发生。此处,当旧代码路径和新代码路径同时启用时,我们会把发送到Orders服务的消息标记为“合成(synthetic)”。这就提醒Orders服务应该将其作为一个正常的请求来处理,但随后必须要将处理结果丢弃或回滚。这对于了解新代码路径正在做什么,并将其与旧路径进行例如各自的结果、负面影响、反馈时间或延迟的影响方面的比较,都是非常有价值的。如果只启用新代码路径而禁用旧代码路径,那么我们只会发送实时请求,其中不含合成(synthetic)指示或标志。
四、指定服务契约
这时候,我们可能应该将单体应用连接到新的Orders服务,用于预订和下单流程。现在对于单体应用来说,是一个明确其在调用Orders服务时在契约或数据方面要求的好时机。当然,Orders服务是一个独立、自治的服务,它承诺可以提供一些特定的功能或 SLA、SLO等[45],但当我们开始构建分布式系统时,有必要了解一下有关服务交互的假设,并理清楚。
通常,我们都是从供应商的角度出发看问题。而在本文案例中,我们则从用户角度出发。在服务提供商看来,用户实际使用或重视的是什么?我们是否可以向提供商提供这种反馈,使他们了解所提供服务的使用情况,以及当服务变更时需要注意的事项,例如,我们不想破坏现有的兼容性。我们想利用用户驱动契约[46]的想法,做出明确的假设(make assumptions explicit)。我们将使用一个名为Pact的项目[47],一种无视编程语言的文档格式,来指定服务之间的契约(重点是用户驱动契约)。据我所知,澳大利亚一家名为DiUS的科技公司[48]在不久前启动了Pact项目。
上图来自Pact文档[49]
让我们再来看一个后端服务的示例[50]。我们将为backend-v2应用程序创建一个用户契约规则,这个规则概述了服务提供商(Orders服务)的期望。当我们将POST HTTP请求发布到/rest/bookings时,我们可以通过以下方式强调一下期望。
@Pact(provider="orders_service", consumer="test_synthetic_order")public RequestResponsePact createFragment(PactDslWithProvider builder) { RequestResponsePact pact = builder .given("available shows") .uponReceiving("booking request") .path("/rest/bookings") .matchHeader("Content-Type", "application/json") .method("POST") .body(bookingRequestBody()) .willRespondWith() .body(syntheticBookingResponseBody()) .status(200) .toPact();return pact;}
public RequestResponsePact createFragment(PactDslWithProvider builder) {
RequestResponsePact pact = builder
.given("available shows")
.uponReceiving("booking request")
.path("/rest/bookings")
.matchHeader("Content-Type", "application/json")
.method("POST")
.body(bookingRequestBody())
.willRespondWith()
.body(syntheticBookingResponseBody())
.status(200)
.toPact();
return pact;
}
当调用提供商提供的服务并将其传入一个特定主体时,会有一个HTTP 200以及与契约匹配的响应值。我们来看一下。首先,先来看看如何指定预订请求主体:
private DslPart bookingRequestBody(){ PactDslJsonBody body = new PactDslJsonBody();body .integerType("performance", 1) .booleanType("synthetic", true) .stringType("email", "[email protected]") .minArrayLike("ticketRequests", 1) .integerType("ticketPrice", 1) .integerType("quantity") .closeObject() .closeArray();return body;}
PactDslJsonBody body = new PactDslJsonBody();
body
.integerType("performance", 1)
.booleanType("synthetic", true)
.stringType("email", "foo@bar.com")
.minArrayLike("ticketRequests", 1)
.integerType("ticketPrice", 1)
.integerType("quantity")
.closeObject()
.closeArray();
return body;
}
Pact-jvm[51]允许我们将pact - JVM - JUnit[52]模块连接到我们最熟悉的测试框架中(即本例中的JUnit)。如果将Arquillian[53]用于组件和集成测试,我们可以用Arquillian Algeron[54]将Pact连接到Arquillian[55]测试中。Alegeron扩展了Pact,使其在Arquillian测试中更好用,而且它还加入了一个通常你通常需要自己手动构建的功能,即在测试时自动发布契约到一个代理或者从一个代理处下载契约。这个功能对于CI或CD流水线至关重要。为了对Java应用程序做用户契约测试,我强烈建议你关注一下Arquillian和Arquillian Algeron[56]。
我们可以创建PactDslJsonBody代码片段,并且使用“通配符”或“在此字段中传入任何内容”的语法。例如,我们用body.integerType("attr_name", default_value)来规定“将存在一个名为X、并且有默认值的属性”。如果去掉默认值参数,那么该值实际上可以是任何值。在此代码片段中,我们只规定请求的结构。注意,在此我们指定了一个合成(synthetic)属性。并且对于每个属性为true的请求,均会有一个具有特定结构的响应值。
在这里,我们声明用户契约(响应值) :
private DslPart syntheticBookingResponseBody() {PactDslJsonBody body = new PactDslJsonBody();body .booleanType("synthetic", true);return body;}
PactDslJsonBody body = new PactDslJsonBody();
body
.booleanType("synthetic", true);
return body;
}
这是一个非常简单的例子:对于这个测试,我们所期望的是,该响应值将会有一个属性为:“synthetic: true”。这很重要,因为当发送合成(synthetic)预订时,我们希望确保Orders服务确认这个预订确实被当做一个合成(synthetic)请求进行处理。如果这个测试成功运行,我们将在目标构建目录中生成这个Pact契约。(在本文例子中,它会出现./target/pacts中。)
{"provider": { "name": "orders_service"},"consumer": { "name": "test_synthetic_order"},"interactions": [ { "description": "booking request", "request": { "method": "POST", "path": "/rest/bookings", "headers": { "Content-Type": "application/json" }, "body": { "synthetic": true, "performance": 1, "ticketRequests": [ { "quantity": 100, "ticketPrice": 1 } ], "email": "[email protected]" }, "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": "available shows" } ] }],"metadata": { "pact-specification": { "version": "3.0.0" }, "pact-jvm": { "version": "" }}}"provider": {
"name": "orders_service"
},
"consumer": {
"name": "test_synthetic_order"
},
"interactions": [
{
"description": "booking request",
"request": {
"method": "POST",
"path": "/rest/bookings",
"headers": {
"Content-Type": "application/json"
},
"body": {
"synthetic": true,
"performance": 1,
"ticketRequests": [
{
"quantity": 100,
"ticketPrice": 1
}
],
"email": "[email protected]"
},
"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": "available shows"
}
]
}
],
"metadata": {
"pact-specification": {
"version": "3.0.0"
},
"pact-jvm": {
"version": ""
}
}
}
此处,可以将契约放入Git[57]、Contract Broker[58]或共享文件系统[59]中。在供应端(Orders服务)上,我们可以创建一个组件测试,来确保提供商提供的服务实际上满足了用户契约中的期望。需要注意的是,用户契约可以有多个,所有这些契约都是可以测试的(尤其当我们对供应商提供的服务进行更改时,可以通过影响测试来了解可能会受到影响的下游用户)
@RunWith(PactRunner.class)@Provider("orders_service")@PactFolder("pact/")public class ConsumerContractTest {private static ConfigurableApplicationContext applicationContext;@TestTargetpublic final Target target = new HttpTarget(8080);@BeforeClasspublic static void startSpring() { applicationContext = SpringApplication.run(Application.class);}@State("available shows")public void testDefaultState() { System.out.println("hi");}}
@Provider("orders_service")
@PactFolder("pact/")
public class ConsumerContractTest {
private static ConfigurableApplicationContext applicationContext;
@TestTarget
public final Target target = new HttpTarget(8080);
@BeforeClass
public static void startSpring() {
applicationContext = SpringApplication.run(Application.class);
}
@State("available shows")
public void testDefaultState() {
System.out.println("hi");
}
}
请注意在这个简单的示范中,我们将从附属./pacts下的文件系统中的一个文件夹中提取契约。
一旦采取了用户驱动契约测试,我们就能更自如地对服务作出变更。有关此问题的工作示例,请参见backend-v2服务[60]以及供应商Orders服务[61]的示例。
五、金丝雀测试或
滚动发布新的微服务
回顾下注意事项
确定群组,并将实时事务流量发送给新的微服务
直接连接数据库仍然是需要的,因为在此期间,事务仍会从两条代码路径通过
将所有流量转到微服务后,就该放弃旧功能了
请注意,在将实时流量发送给微服务后,回滚到旧代码路径将遇到困难,需要协调
该场景另外一个重要部分是,我们需要通过具有特征标志的新部署来发送一小部分流量。我们可以使用Istio来精确地控制被调用的后端。例如,我们已经部署了backend-v1,其已完全发布,并接受生产负载。当我们部署backend-v2,且其具有控制新代码路径的特性标志时,我们可以使用Istio来进行金丝雀发布,这与此前文章中的做法类似。从只发送1%的流量开始,然后缓慢增加( 5%,25%等),发送的同时注意时刻观察效果。我们还可以将这些特性进行切换,以便同时启用旧代码路径和新代码路径。这是一项非常强大的技术,能帮助我们大大降低微服务架构改变和迁移时所带来的风险。下面是一个istio route - rule的示例:
apiVersion: config.istio.io/v1alpha2kind: RouteRulemetadata: name: backend-v2spec: destination: name: backend precedence: 20 route: - labels: version: v1weight: 99 - labels: version: v2weight: 1
kind: RouteRule
metadata:
name: backend-v2
spec:
destination:
name: backend
precedence: 20
route:
- labels:
version: v1
weight: 99
- labels:
version: v2
weight: 1
一些需要注意的事项:到了现在这一步,我们可能会使用新的Orders服务来同时启用旧代码路径和新代码路径,且新Orders服务会执行合成事务。到目前为止,所描述的金丝雀将适用于1%的任何流量。如果仅向内部用户或一小部分外部用户发布,并实际通过实时Orders服务(即非模拟流量)对它们进行发布,那么这可能是有用的。通过将基于用户的修改路径和将用户分组到队列的FF4j配置相结合,我们就可以启用新Orders服务的完整代码路径(包括实时流量、非合成事务性载荷等)。然而,这一点的关键是,一旦用户已被定向到Orders的实时代码路径, 为了方便以后的调用,会一直这样发送。这是因为一旦用新服务进行下单,该Orders将不会出现在单体应用的数据库中。对该用户的所有查询或更新都应该始终通过新的微服务。
此时,我们可以观察流量模式或服务表现,并做出是否增加发布范围的决定。最终,我们的目的是将所有流量发送到新服务上。
如果数据不在单体应用中,该怎么办? 你可能会选择什么都不做——新Orders服务现在是订单或预订逻辑加数据的合法所有者。对于这些新Orders, 如果觉得有必要在单体应用之间进行集成,你可以选择发布新Orders服务中的事件以及订单详细信息。这样,单体应用也可以捕捉这些事件,并将它们储存在其数据库中。其他服务也可以监听这些事件,并对其作出反应。事件发布机制还是有用的。
好啦,这篇博文已经够长了!整体内容还剩下两个小节,分别是“离线数据提取转换加载(ETL)或迁移”和“断开或解耦数据存储”。因为我想妥善处理这部分内容,所以这里必须收尾了,剩余的部分会在第四部分呈现!第五部分将是网络广播或视频或demo演示,在展现整体内容。
原文地址:http://blog.christianposta.com/microservices/low-risk-monolith-to-microservice-evolution-part-iii/
参考地址:
[1] http://arquillian.org/arquillian-algeron/
[2] https://projects.spring.io/spring-boot/
[3] http://wildfly.org
[4] http://wildfly-swarm.io
[5] http://www.apicur.io
[6] https://github.com/teiid/teiid-spring-boot
[7] http://debezium.io
[8] http://camel.apache.org
[9] https://istio.io
[10] http://www.liquibase.org
[11] https://ff4j.org
[12] https://kubernetes.io
[13] https://www.openshift.org
[14] https://fabric8.io
[15] http://arquillian.org
[16] https://github.com/pact-foundation/pact-specification
[17] http://arquillian.org/arquillian-algeron/
[18] https://hoverfly.io
[19] https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-testing.html
[20] http://rest-assured.io
[21] http://arquillian.org/arquillian-cube/
[22] https://twitter.com/VaughnVernon
[23] https://vaughnvernon.co/?p=838
[24] https://github.com/ticket-monster-msa/monolith/tree/master/orders-service
[25] http://teiid.jboss.org
[26] http://searchdatamanagement.techtarget.com/definition/data-federation-technology
[27] https://twitter.com/rareddy
[28、29] https://github.com/teiid/teiid-spring-boot/blob/master/docs/UserGuide.adoc
[30] https://github.com/ticket-monster-msa/monolith/tree/master/orders-service/src/main/java/org/ticketmonster/orders/domain
[31] https://github.com/ticket-monster-msa/monolith/blob/master/orders-service/src/main/java/org/ticketmonster/orders/domain/Ticket.java
[32] https://github.com/teiid/teiid-spring-boot/blob/master/docs/Reference.adoc
[33] https://github.com/teiid/teiid-spring-boot/blob/master/samples/rdbms/src/main/java/org/teiid/spring/example/CustomerRepository.java
[34] https://github.com/teiid/teiid-spring-boot/tree/master/odata
[35] https://www.amazon.com/Working-Effectively-Legacy-Michael-Feathers/dp/0131177052
[36、37] https://ff4j.org
[38] http://blog.launchdarkly.com/feature-flags-dark-launches-and-canary-releases-for-all-launchdarkly-first-year-in-review/
[39] http://blog.launchdarkly.com/secret-to-facebooks-hacker-engineering-culture/
[40] https://blog.turbinelabs.io/deploy-not-equal-release-part-one-4724bc1e726b
[41] https://github.com/ff4j/ff4j/wiki/Advanced-Concepts
[42] https://github.com/ticket-monster-msa/monolith/blob/master/backend-v2/src/main/java/org/jboss/examples/ticketmonster/util/FF4jFactory.java
[43] https://github.com/ff4j/ff4j/wiki/Store-Technologies
[44] https://github.com/ff4j/ff4j/wiki/Web-Concepts#web-console
[45] http://blog.christianposta.com/microservices/3-easy-things-to-do-to-make-your-microservices-more-resilient/
[46] https://martinfowler.com/articles/consumerDrivenContracts.html
[47] https://github.com/pact-foundation/pact-specification
[48] https://twitter.com/dius_au
[49] https://docs.pact.io/documentation/
[50] https://github.com/ticket-monster-msa/monolith/tree/master/backend-v2
[51] https://github.com/DiUS/pact-jvm
[52]https://github.com/DiUS/pact-jvm/tree/master/pact-jvm-provider-junit
[53、55] http://arquillian.org
[54、56] http://arquillian.org/arquillian-algeron/
[57] http://arquillian.org/arquillian-algeron/#_git_publisher
[58] http://arquillian.org/arquillian-algeron/#_pact_broker
[59] http://arquillian.org/arquillian-algeron/#_folder_publisher
[60] https://github.com/ticket-monster-msa/monolith/tree/master/backend-v2
[61] https://github.com/ticket-monster-msa/monolith/tree/master/orders-service
关于EAWorld:微服务,DevOps,数据治理,移动架构原创技术分享,长按二维码关注