Java-云原生应用(二)

88 阅读59分钟

Java 云原生应用(二)

原文:zh.annas-archive.org/md5/3AA62EAF8E1B76B168545ED8887A16CF

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:扩展您的云原生应用

在理解了设计原则之后,让我们拿出在第二章中开发的骨架服务,编写您的第一个云原生应用,并对它们进行一些真正的工作,使它们能够投入生产。

我们定义了两个获取服务;getProduct用于给定产品 ID,getProducts用于给定类别。这两个服务具有高度的非功能性要求。它们必须始终可用,并以尽可能低的延迟提供数据。以下步骤将带领我们实现这一目标:

  1. 访问数据:服务访问跨各种资源的数据

  2. 缓存:进行缓存的选项及其考虑因素

  3. 应用 CQRS:使我们能够拥有不同的数据模型来服务不同的请求

  4. 错误处理:如何恢复,发送什么返回代码,以及实现断路器等模式

我们还将研究添加修改数据的方法,例如insertupdatedelete。在本章中,我们将涵盖:

  • 验证:确保数据在处理之前是干净的

  • 保持两个 CQRS 模型同步:数据一致性

  • 事件驱动和异步更新:它如何扩展架构并同时解耦

实现获取服务

让我们继续开发在第二章中开发的product项目,编写您的第一个云原生应用。我们将在讨论概念的同时逐步增强它。

让我们仔细考虑一下我们两个服务的数据库。getProduct返回产品信息,而getProducts搜索属于该类别的产品列表。首先,对于简单和标准的要求,这两个查询都可以由关系数据库中的单个数据模型回答:

  1. 您将在一个固定数量的列中的产品表中存储产品。

  2. 然后,您将对类别进行索引,以便对其进行的查询可以快速运行。

现在,这个设计对于大多数中等规模公司的要求来说都是可以的。

简单的产品表

让我们在标准关系数据库中使用产品表,并使用 Spring Data 在我们的服务中访问它。Spring Data 提供了优秀的抽象,以使用Java 持久化 APIJPA),并使编写数据访问对象DAO)变得更加容易。Spring Boot 进一步帮助我们开始时编写最少的代码,并在前进时进行扩展。

Spring Boot 可以与嵌入式数据库一起工作,例如 H2、HSQLDB 或外部数据库。进程内嵌入式数据库在我们的 Java 服务中启动一个进程,然后在进程终止时终止。这对于开始是可以的。稍后,依赖项和 URL 可以更改为指向实际数据库。

您可以从第二章中获取项目,编写您的第一个云原生应用,并添加以下步骤,或者只需从 GitHub(github.com/PacktPublishing/Cloud-Native-Applications-in-Java)下载已完成的代码:

  1. Maven POM:包括 POM 依赖项:

这将告诉 Spring Boot 包含 Spring Boot starter JPA 并在嵌入模式下使用 HSQLDB。

  1. 实体:根据 JPA,我们将开始使用实体的概念。我们已经有一个名为Product的领域对象来自我们之前的项目。重构它以放入一个实体包中。然后,添加@Entity@Id@Column的标记,如下所示的Product.java文件:
package com.mycompany.product.entity ; 

import javax.persistence.Column; 
import javax.persistence.Entity; 
import javax.persistence.GeneratedValue; 
import javax.persistence.GenerationType; 
import javax.persistence.Id; 

@Entity 
public class Product { 

   @Id 
   @GeneratedValue(strategy=GenerationType.AUTO) 
   private int id ; 

   @Column(nullable = false) 
   private String name ; 

   @Column(nullable = false) 
   private int catId ; 

其余的代码,如构造函数和 getter/setter,保持不变。

  1. 存储库:Spring Data 提供了一个存储库,类似于 DAO 类,并提供了执行数据的创建读取更新删除CRUD)操作的方法。CrudRepository接口中已经提供了许多标准操作。从现在开始,我们将只使用查询操作。

在我们的情况下,由于我们的领域实体是Product,所以存储库将是ProductRepository,它扩展了 Spring 的CrudRepository,并管理Product实体。在扩展期间,需要使用泛型指定实体和主键的数据类型,如下面的ProductRepository.java文件所示:

package com.mycompany.product.dao; 

import java.util.List; 
import org.springframework.data.repository.CrudRepository; 
import com.mycompany.product.entity.Product; 

public interface ProductRepository extends CrudRepository<Product, Integer> { 

   List<Product> findByCatId(int catId); 
} 

首先要考虑的问题是,这段代码是否足够工作。它只有一个接口定义。如何能足够处理我们的两个方法,即getProduct(根据产品 ID)和getProducts(根据类别)?

Spring Data 中发生的魔术有助于处理样板代码。CrudRepository接口带有一组默认方法来实现最常见的操作。这些包括savedeletefindcountexists操作,这些操作足以满足大部分查询和更新任务。我们将在本章的后半部分讨论update操作,但让我们先专注于查询操作。

根据CrudRepository中的findOne方法,已经存在根据 ID 查找产品的操作。因此,我们不需要显式调用它。

在我们的ProductRepository接口中,根据给定类别查找产品的任务由findByCatId方法完成。Spring Data 存储库基础设施内置的查询构建器机制对于构建存储库实体的查询非常有用。该机制会剥离方法的前缀,如findreadquerycountget,然后根据实体解析剩余部分。该机制非常强大,因为关键字和组合的选择意味着方法名足以执行大部分查询操作,包括操作符(and/or)、distinct 子句等。请参阅 Spring Data 参考文档(docs.spring.io/spring-data/jpa/docs/current/reference/html/)了解详细信息。

这些约定允许 Spring Data 和 Spring Boot 根据解析接口来注入方法的实现。

  1. 更改服务:在第二章中,编写您的第一个云原生应用程序,我们的product服务返回了虚拟的硬编码数据。让我们将其更改为针对数据库的有用内容。我们通过使用之前定义的ProductRepository接口,并通过@Autowiring注入到我们的ProductService类中来实现这一点,如下面的ProductService.java文件所示:
@RestController 
public class ProductService { 

   @Autowired 
   ProductRepository prodRepo ; 

   @RequestMapping("/product/{id}") 
   Product getProduct(@PathVariable("id") int id) { 
         return prodRepo.findOne(id); 
   } 

   @RequestMapping("/products") 
   List<Product> getProductsForCategory(@RequestParam("id") int id) { 
         return prodRepo.findByCatId(id); 
   } 
} 

存储库中的findOne方法根据主键获取对象,我们定义的findByCatId方法有助于根据类别查找产品。

  1. 模式定义:目前,我们将模式创建留给hibernate自动生成脚本。由于我们确实想要看到生成的脚本,让我们在application.properties文件中启用对类的logging,如下所示:
logging.level.org.hibernate.tool.hbm2ddl=DEBUG 
logging.level.org.hibernate.SQL=DEBUG 
  1. 测试数据:由于我们将稍后插入产品,因此需要初始化数据库并添加一些产品。因此,请将以下行添加到import.sql中,并将其放在资源中(与application.properties和引导文件所在的位置):
-- Adding a few initial products
insert into product(id, name, cat_Id) values (1, 'Apples', 1) 
insert into product(id, name, cat_Id) values (2, 'Oranges', 1) 
insert into product(id, name, cat_Id) values (3, 'Bananas', 1) 
insert into product(id, name, cat_Id) values (4, 'Carrot', 2) 
  1. **让 Spring Data 和 Spring Boot 来解决其余问题:**但在生产应用程序中,我们希望对连接 URL、用户 ID、密码、连接池属性等进行精细控制。

运行服务

要运行我们的product服务,请执行以下步骤:

  1. 启动 Eureka 服务器(就像我们在第二章中所做的那样,编写您的第一个云原生应用程序),使用EurekaApplication类。我们将始终保持 Eureka 服务运行。

  2. 一旦Eureka项目启动,运行product服务。

注意由hibernate生成的日志。它首先自动使用 HSQLDB 方言,然后创建并运行以下Product表 SQL:

HHH000227: Running hbm2ddl schema export 
drop table product if exists 
create table product (id integer generated by default as identity (start with 1), cat_id integer not null, name varchar(255) not null, primary key (id)) 
HHH000476: Executing import script '/import.sql' 
HHH000230: Schema export complete 

一旦服务开始监听端口,请在浏览器中发出查询:http://localhost:8082/product/1。这将返回以下内容:

{"id":1,"name":"Apples","catId":1} 

当您看到日志时,您会观察到后台运行的 SQL:

select product0_.id as id1_0_0_, product0_.cat_id as cat_id2_0_0_, product0_.name as name3_0_0_ from product product0_ where product0_.id=? 

现在,再次发出一个返回给定类别产品的查询:http://localhost:8082/products?id=1。这将返回以下内容:

[{"id":1,"name":"Apples","catId":1},{"id":2,"name":"Oranges","catId":1},{"id":3,"name":"Bananas","catId":1}] 

为此条件运行的 SQL 如下:

select product0_.id as id1_0_, product0_.cat_id as cat_id2_0_, product0_.name as name3_0_ from product product0_ where product0_.cat_id=? 

尝试使用不同的类别,http://localhost:8082/products?id=2,将返回如下内容:

[{"id":4,"name":"Carrot","catId":2}] 

这完成了一个针对数据源的简单查询服务。

为了生产目的,这将需要增强以将标准数据库作为 Oracle、PostgreSQL 或 MySQL 数据库。您将在类别列上引入索引,以便查询运行更快。

传统数据库的局限性

但是在以下情况下,公司扩大产品和客户会发生什么?

  • 关系数据库的可伸缩性(产品数量和并发请求数量)成为瓶颈。

  • 产品结构根据类别不同,在关系数据库的固定模式中很难建模。

  • 搜索条件开始扩大范围。目前,我们只按类别搜索;以后,我们可能希望按产品描述、过滤字段以及类别描述进行搜索。

单个关系数据库是否足以满足所有需求?

让我们用一些设计技术来解决这些问题。

缓存

随着服务在数据量和并发请求方面的扩展,数据库将开始成为瓶颈。为了扩展,我们可以采用缓存解决方案,通过从缓存中服务请求来减少对数据库的访问次数,如果值在缓存中可用的话。

Spring 提供了通过注解包含缓存的机制,以便 Spring 可以返回缓存值而不是调用实际处理或检索方法。

从概念上讲,缓存分为两种类型,如下节所讨论的。

本地缓存

本地缓存存在于与服务相同的 JVM 中。它的范围是有限的,因为它只能被服务实例访问,并且必须完全由服务实例管理。

让我们首先使我们的产品在本地缓存中可缓存。

Spring 3.1 引入了自己的注释来返回缓存条目、清除或填充条目。但后来,JSR 107 JCache 引入了不同的注释。Spring 4.1 及更高版本也支持这些。

让我们首先使用 Spring 的注释:

  1. 告诉 Spring 应用程序启用缓存并寻找可缓存的实例。这是一次性声明,因此最好在启动类中完成。在主类中添加@``EnableCaching注释:
@SpringBootApplication
@EnableDiscoveryClient 
@EnableCaching 
public class ProductSpringApp { 
  1. 在我们的ProductRepository中启用缓存以通过添加可缓存注释获取产品,我们将提供一个明确的缓存名称,并将用于此方法:
public interface ProductRepository extends CrudRepository<Product, Integer> { 

   @Cacheable("productsByCategoryCache") 
   List<Product> findByCatId(int catId); 
} 

现在,再次运行服务,并观察当您在浏览器中运行以下一组查询时的日志:

  1. http://localhost:8082/products?id=1

  2. http://localhost:8082/products?id=2

  3. http://localhost:8082/products?id=1

  4. http://localhost:8082/products?id=2

您会看到以下 SQL 只被触发了两次:

select product0_.id as id1_0_, product0_.cat_id as cat_id2_0_, product0_.name as name3_0_ from product product0_ where product0_.cat_id=? 

这意味着仓库只有在缓存中找不到类别条目时才执行了findByCatId方法。

底层

尽管 Spring 在幕后处理了许多关注点,如缓存实现,但重要的是要了解正在发生的事情,并意识到其中的局限性。

在内部,缓存是通过内部类(如缓存管理器和缓存解析器)实现的。当没有提供缓存产品或框架时,Spring 默认使用ConcurrentHashMap。Spring 的缓存实现了许多其他本地缓存,如 EHCache、Guava 和 Caffeine。

查看 Spring 文档(docs.spring.io/spring/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)以获取更多诸如sync=true和条件缓存等复杂性。

本地缓存的局限性

本地缓存在有限的用例中很有用(例如非更改静态数据),因为使用 Spring 注释(如@CachePut@CacheEvict等)在一个服务中进行的更新无法与运行多个服务实例的其他实例上的缓存同步,以实现负载平衡或弹性目的。

分布式缓存

Hazelcast、Gemfire 和/或 Coherence 等分布式缓存是网络感知的,缓存实例作为进程模型(对等模型)运行,其中缓存是服务运行时的一部分,或者作为客户端-服务器模型运行,其中缓存请求从服务到单独的专用缓存实例。

对于此示例,我们选择了 Hazelcast,因为它是一个非常轻量但功能强大的分布式缓存解决方案。它还与 Spring Boot 集成得非常好。以下是如何操作的:

  1. 在 POM(Maven 文件)中,添加对hazelcast-spring的依赖。hazelcast-spring具有一个HazelcastCacheManager,用于配置要使用的 Hazelcast 实例:
<dependency> 
   <groupId>org.springframework.boot</groupId> 
   <artifactId>spring-boot-starter-cache</artifactId> 
</dependency> 
<dependency> 
   <groupId>com.hazelcast</groupId> 
   <artifactId>hazelcast-spring</artifactId>              
</dependency>
  1. 由于 Hazelcast 是一个分布式缓存,它需要元素是可序列化的。因此,我们需要确保我们的Product实体是可序列化的:
public class Product implements Serializable {
  1. 一个简化的 Hazelcast 配置文件,告诉各种 Hazelcast 实例如何发现并与彼此同步:
<hazelcast  
   xsi:schemaLocation="http://www.hazelcast.com/schema/config http://www.hazelcast.com/schema/config/hazelcast-config-3.6.xsd" 
   > 

   <group> 
         <name>ProductCluster</name> 
         <password>letmein</password> 
   </group> 
   <network> 
        <join> 
            <multicast enabled="true"/> 
        </join> 
    </network> 
</hazelcast>

现在,让我们测试这些更改。为此,我们必须运行两个product服务的实例来检查它是否有效。我们可以通过更改端口号来运行两个实例:

  1. 使用端口8082(已配置)运行服务。

  2. application.properties更改为8083

  3. 再次运行服务。

您将在一个服务上看到 Hazelcast 消息,该服务启动如下:

Loading 'hazelcast.xml' from classpath. 
[LOCAL] [ProductCluster] [3.6.5] Picked Address[169.254.104.186]:5701, using socket  
[169.254.104.186]:5701 [ProductCluster] [3.6.5] Hazelcast 3.6.5 (20160823 - e4af3d9) starting 
Members [1] { 
Member [169.254.104.186]:5701 this 
}

但是,一旦第二个服务启动,成员定义就会被2更新:

Members [2] { 
   Member [169.254.104.186]:5701 
   Member [169.254.104.186]:5702 this 
} 

现在,在浏览器上运行以下查询,并观察控制台中的日志:

  1. http://localhost:8082/products?id=1

  2. http://localhost:8082/products?id=2

  3. http://localhost:8082/products?id=1

  4. http://localhost:8082/products?id=2

  5. http://localhost:8083/products?id=1

  6. http://localhost:8083/products?id=2

您会发现在 SQL 中,调试日志只在第一个服务中出现两次。其他四次,缓存条目都是从 Hazelcast 中提取的。与以前的本地缓存不同,缓存条目在两个实例之间是同步的。

将 CQRS 应用于分离数据模型和服务

分布式缓存是解决扩展问题的一种方法。但是,它引入了某些挑战,例如缓存陈旧(使缓存与数据库同步)和额外的内存需求。

此外,缓存是过渡到 CQRS 范例的开始。重新审视我们在第三章设计您的云原生应用程序中讨论的 CQRS 概念。

查询是从缓存中回答的(除了第一次命中),这是查询与从记录系统(即数据库)传递的命令进行分离,并稍后更新查询模型(缓存更新)。

让我们在 CQRS 中迈出下一步,以便清晰地进行这种分离。CQRS 引入的复杂性是:

  • 需要维护两个(或多个)模型,而不是一个

  • 当数据发生变化时更新所有模型的开销

  • 不同模型之间的一致性保证

因此,只有在用例需要高并发、高容量和快速敏捷性需求的情况下才应遵循这种模式。

关系数据库上的物化视图

物化视图是 CQRS 的最简单形式。如果我们假设对产品的更新发生的频率比对产品和类别的读取频率低,那么我们可以有两种不同的模型支持getProduct(根据 ID)和getProducts(根据给定的类别)。

搜索查询getProducts针对此视图,而基于主键的传统getProduct则转到常规表。

如果数据库(例如 Oracle)支持,这应该很容易。如果数据库不支持物化视图,默认情况下可以手动完成,如果有需要,可以通过手动更新统计信息或摘要表来完成,当主产品表使用触发器或更好的事件驱动架构(例如业务事件)更新时。我们将在本章的后半部分看到这一点,当我们为我们的服务集添加addProduct功能时。

Elasticsearch 和文档数据库

为了解决灵活模式、高搜索能力和更高容量处理的限制,我们可以选择 NoSQL 技术:

  • 为了提供不同类型的产品,我们可以选择使用文档数据库及其灵活的模式,例如 MongoDB。

  • 为了处理搜索请求,基于 Lucene 的 Elasticsearch 技术由于其强大的索引能力将是有益的。

为什么不仅使用文档数据库或 Elasticsearch?

也可以考虑以下选项:

  • Elasticsearch 通常是一种补充技术,而不是用作主数据库。因此,产品信息应该在可靠的关系型或 NoSQL 数据库中维护。

  • 像 MongoDB 这样的文档数据库也可以构建索引。但是,性能或索引能力无法与 Elasticsearch 相匹敌。

这是一个典型的适用场景示例。您的选择将取决于您的用例:

  • 无论您是否需要灵活的模式

  • 可扩展和高容量的应用程序

  • 高度灵活的搜索需求

文档数据库上的核心产品服务

保持 REST 接口不变,让我们将内部实现从使用关系数据库(例如我们的 HSQLDB)更改为 MongoDB。我们将 MongoDB 作为服务器单独运行,而不是在进程中运行,例如 HSQLDB。

准备 MongoDB 的测试数据

下载和安装 MongoDB 的步骤如下:

  1. 安装 MongoDB。在 MongoDB 网站上(www.mongodb.com/)可以很容易地按照各种平台的说明进行操作。

  2. 运行mongod.exe启动 MongoDB 的一个实例。

  3. 创建一个包含我们示例数据的测试文件(类似于import.sql)。但是,这次我们将数据保留在 JSON 格式中,而不是 SQL 语句。products.json文件如下:

{"_id":"1","name":"Apples","catId":1} 
{"_id":"2","name":"Oranges","catId":1} 
{"_id":"3","name":"Bananas","catId":1} 
{"_id":"4","name":"Carrot","catId":2} 

请注意_id,这是 MongoDB 的主键表示法。如果您不提供_id,MongoDB 将使用ObjectId定义自动生成该字段。

  1. 将示例数据加载到 MongoDB。我们将创建一个名为masterdb的数据库,并加载到一个名为product的集合中:
mongoimport --db masterdb --collection product --drop --file D:datamongoscriptsproducts.json 
  1. 通过使用use masterdb后,通过命令行检查数据是否加载,使用db.product.find()命令如下:

创建产品服务

创建product服务的步骤如下:

  1. 最好从零开始。从之前的带有 Hazelcast 和 HSQLDB 的示例项目复制或从 GitHub 存储库中拉取(github.com/PacktPublishing/Cloud-Native-Applications-in-Java)。

  2. 调整 Maven POM 文件以具有以下依赖项。删除其他依赖项,因为它们对我们的小例子不是必需的:

<dependencies> 
         <dependency> 
               <groupId>org.springframework.boot</groupId> 
               <artifactId>spring-boot-starter-web</artifactId> 
         </dependency> 
         <dependency> 
               <groupId>org.springframework.boot</groupId> 
               <artifactId>spring-boot-starter-actuator</artifactId> 
         </dependency> 
         <dependency> 
               <groupId>org.springframework.cloud</groupId> 
               <artifactId>spring-cloud-starter-eureka</artifactId> 
         </dependency> 
         <dependency> 
               <groupId>org.springframework.boot</groupId> 
               <artifactId>spring-boot-starter-data- 
                mongodb</artifactId> 
        </dependency> 
</dependencies> 
  1. Product实体应该只有一个@Id字段。在类级别放置@Document注解是可选的。如果不这样做,首次插入性能会受到影响。现在,让我们在Product.java文件中放置注解:
@Document 
public class Product  { 

   @Id 
   private String id ;      
   private String name ;    
   private int catId ; 

   public Product() {} 

   .... (other constructors, getters and setters) 

请注意,这里的idString而不是int。原因是 NoSQL 数据库在生成 ID 时(GUID)比关系系统(如数据库)中的递增整数更好。原因是数据库变得更加分布式,因此相对于生成 GUID,可靠地生成递增数字稍微困难一些。

  1. ProductRepository现在扩展了MongoRepository,其中有从 MongoDB 检索产品的方法,如ProductRepository.java文件中所示:
package com.mycompany.product.dao; 

import java.util.List; 
import org.springframework.data.mongodb.repository.MongoRepository; 
import com.mycompany.product.entity.Product; 

public interface ProductRepository extends MongoRepository<Product, String> { 

   List<Product> findByCatId(int catId); 
}
  1. 我们只需向application.properties添加一个属性,告诉服务从 MongoDB 的masterdb数据库获取数据。此外,最好在不同的端口上运行它,这样我们以后可以并行运行服务,如果我们想这样做的话:
server.port=8085 
eureka.instance.leaseRenewalIntervalInSeconds=5 
spring.data.mongodb.database=masterdb 

由于接口没有更改,因此ProductService类也不会更改。

现在,启动 Eureka 服务器,然后启动服务,并在浏览器中执行以下查询:

  1. http://localhost:8085/products?id=1

  2. http://localhost:8085/products?id=2

  3. http://localhost:8085/product/1

  4. http://localhost:8085/product/2

您将得到与以前相同的 JSON。这是微服务的内部实现更改。

拆分服务

让我们从学习的角度采用所建议的分离的简单实现。由于我们正在分离主模型和搜索模型,将服务拆分是有意义的,因为搜索功能可以被视为产品主模型的下游功能。

对于类别的getProducts功能是搜索功能的一部分,它本身可以成为一个复杂且独立的业务领域。因此,现在是重新考虑是否有意义将它们保留在同一个微服务中,还是将它们拆分为核心产品服务和产品搜索服务的时候了。

产品搜索服务

让我们创建一个专门进行高速、高容量搜索的新微服务。支持搜索微服务的搜索数据存储不需要是产品数据的主数据,而可以作为补充的搜索模型。Elasticsearch 在各种搜索用例中都非常受欢迎,并且符合极端搜索需求的需求。

准备 Elasticsearch 的测试数据

以下是准备 Elasticsearch 的测试数据的步骤:

  1. 安装 Elastic 版本。使用版本 2.4.3,因为最近的 5.1 版本与 Spring Data 不兼容。Spring Data 使用在端口9300上与服务器通信的 Java 驱动程序,因此在客户端和服务器上具有相同的版本非常重要。

  2. 创建一个包含我们的样本数据的测试文件(类似于products.json)。格式与以前的情况略有不同,但是针对 Elasticsearch 而不是 MongoDB。products.json文件如下:

{"index":{"_id":"1"}} 
{"id":"1","name":"Apples","catId":1} 

{"index":{"_id":"2"}} 
{"id":"2","name":"Oranges","catId":1} 

{"index":{"_id":"3"}} 
{"id":"3","name":"Bananas","catId":1} 

{"index":{"_id":"4"}} 
{"id":"4","name":"Carrot","catId":2} 
  1. 使用 Postman 或 cURL 调用 Elasticsearch 上的 REST 服务来加载数据。请参阅以下屏幕截图以查看 Postman 扩展中的输出。在 Elasticsearch 中,数据库的等价物是索引,我们可以命名我们的索引为product。Elasticsearch 还有一个类型的概念,但稍后再说:

  1. 通过在 Postman、浏览器或 cURL 中运行简单的*查询来检查数据是否已加载:
http://localhost:9200/product/_search?q=*&pretty

因此,您应该得到添加的四个产品。

创建产品搜索服务

到目前为止,我们已经完成了两个数据库,现在你一定对这个流程很熟悉了。这与我们为 HSQLDB 和 MongoDB 所做的并没有太大的不同。复制 Mongo 项目以创建productsearch服务,并像以前一样对 Maven POM、实体、存储库类和应用程序属性进行更改:

  1. 在 Maven POM 中,spring-boot-starter-data-elasticsearch取代了之前两个服务示例中的spring-boot-starter-data-mongodbspring-boot-starter-data-jpa

  2. Product实体中,@Document现在表示一个 Elasticsearch 文档。它应该有一个定义相同的indextype,因为我们用来加载测试数据,如Product.java文件所示:

package com.mycompany.product.entity ; 

import org.springframework.data.annotation.Id; 
import org.springframework.data.elasticsearch.annotations.Document; 

@Document(indexName = "product", type = "external" ) 
public class Product  { 

   @Id 
   private String id ;      
   private String name ;    
   private int catId ;           //Remaining class is same as before 
  1. ProductRepository现在扩展了ElasticsearchRepository,如ProductRepository.java文件所示:
package com.mycompany.product.dao; 

import java.util.List; 
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; 
import com.mycompany.product.entity.Product; 

public interface ProductRepository extends ElasticsearchRepository<Product, String> { 

   List<Product> findByCatId(int catId); 
} 
  1. application.properties中进行更改,指示elasticsearch的服务器模型(与嵌入式模型相对,就像我们为 HSQLDB 所做的那样):
server.port=8086 
eureka.instance.leaseRenewalIntervalInSeconds=5 

spring.data.elasticsearch.repositories.enabled=true 
spring.data.elasticsearch.cluster-name=elasticsearch 
spring.data.elasticsearch.cluster-nodes=localhost:9300 

现在,启动 Eureka 服务器,然后启动productsearch服务,并按照以下顺序在浏览器中发出以下查询:

  1. http://localhost:8085/products?id=1

  2. http://localhost:8085/products?id=2

你将得到与之前相同的 JSON。这是微服务的内部实现变化,从第二章中的硬编码实现到 HSQLDB、MongoDB,现在是 Elasticsearch。

由于 Spring Data 框架,访问驱动程序并与其通信的代码已经被大大抽象化,所以我们只需要添加以下内容:

  1. Maven POM 文件中的依赖项。

  2. 在存储库的情况下扩展的基类。

  3. 用于实体的注解。

  4. 在应用程序属性中配置的属性。

数据更新服务

到目前为止,我们已经看过了获取数据。让我们看一些数据修改操作,比如创建、更新和删除(CRUD 操作)。

鉴于 REST 在基于云的 API 操作中的流行度,我们将通过 REST 方法进行数据操作。

让我们选择在本章之前使用 Hazelcast 的 HSQLDB 示例。

REST 惯例

GET 方法是一个不用大脑思考的选择,但是对于创建、插入和删除等操作的方法的选择需要一些考虑。我们将遵循行业指南的惯例:

URLHTTP 操作服务方法描述
/product/{id}GETgetProduct获取给定 ID 的产品
/productPOSTinsertProduct插入产品并返回一个新的 ID
/product/{id}PUTupdateProduct使用请求体中的数据更新给定 ID 的产品
/product/{id}DELETEdeleteProduct删除提供的 ID 的产品

让我们看看ProductService类中的实现。我们已经在本章前面有了getProduct的实现。让我们添加其他方法。

插入产品

暂且不考虑验证(我们一会儿会讨论),插入看起来非常简单,实现 REST 接口。

我们将POST操作映射到insertProduct方法,在实现中,我们只需在已经定义的存储库上调用save

@RequestMapping(value="/product", method = RequestMethod.POST) 
ResponseEntity<Product> insertProduct(@RequestBody Product product) { 

   Product savedProduct = prodRepo.save(product) ; 
   return new ResponseEntity<Product>(savedProduct, HttpStatus.OK);         
}  

注意一下我们之前编码的getProduct方法有一些不同之处:

  • 我们在@RequestMapping中添加了一个POST方法,这样当使用 HTTP POST时,URL 将映射到insertProduct方法。

  • 我们从@RequestBody注解中捕获product的详细信息。这在插入新产品时应该提供。Spring 会为我们将 JSON(或 XML)映射到Product类。

  • 我们返回一个ResponseEntity而不是像getProduct方法中那样只返回一个Product对象。这使我们能够自定义 HTTP 响应和标头,在 REST 架构中这很重要。对于成功的插入,我们返回一个 HTTP OK200)响应,告诉客户端他添加产品的请求成功了。

测试

测试我们的insertProduct方法的步骤如下:

  1. 启动 Eureka 服务器,然后启动product服务(假设它在8082上监听)。

  2. 请注意,现在浏览器不够用了,因为我们想要指示 HTTP 方法并提供响应主体。改用 Postman 或 cURL。

  3. 将内容类型设置为 application/json,因为我们将以 JSON 格式提交新的产品信息。

  4. 以 JSON 格式提供产品信息,例如{"name":"Grapes","catId":1}。注意我们没有提供产品 ID:

  1. 点击发送。你会得到一个包含产品 JSON 的响应。这次,ID 将被填充。这是存储库生成的 ID(它又从底层数据库中获取)。

更新产品

在这里,我们将使用PUT方法,指示 URL 模式中要更新的产品的 ID。与POST方法一样,要更新的产品的详细信息在@RequestBody注解中提供:

@RequestMapping(value="/product/{id}", method = RequestMethod.PUT) 
ResponseEntity<Product> updateProduct(@PathVariable("id") int id, @RequestBody Product product) { 

   // First fetch an existing product and then modify it.  
   Product existingProduct = prodRepo.findOne(id);  

   // Now update it back  
   existingProduct.setCatId(product.getCatId()); 
   existingProduct.setName(product.getName()); 
   Product savedProduct = prodRepo.save(existingProduct) ; 

   // Return the updated product with status ok  
   return new ResponseEntity<Product>(savedProduct, HttpStatus.OK);         
} 

实现包括:

  1. 从存储库中检索现有产品。

  2. 根据业务逻辑对其进行更改。

  3. 将其保存回存储库。

  4. 返回更新后的产品(供客户端验证),状态仍然是OK

如果你没有注意到,最后两个步骤与插入情况完全相同。只是检索和更新产品是新步骤。

测试

测试我们的insertProduct方法的步骤如下:

  1. 与插入产品一样,再次启动 Eureka 和ProductService

  2. 让我们将第一个产品的产品描述更改为Fuji Apples。所以,我们的 JSON 看起来像{"id":1,"name":"Fuji Apples","catId":1}

  3. 准备 Postman 提交PUT请求如下:

  1. 点击发送。你会得到一个包含 JSON {"id":1,"name":"Fuji Apples","catId":1}的响应 200 OK。

  2. 发送一个GET请求http://localhost:8082/product/1来检查变化。你会发现apples变成了Fuji Apples

删除产品

删除产品的映射和实现如下:

@RequestMapping(value="/product/{id}", method = RequestMethod.DELETE) 
ResponseEntity<Product> deleteProduct(@PathVariable("id") int id) {         
   prodRepo.delete(id); 
   return new ResponseEntity<Product>(HttpStatus.OK);           
} 

我们在存储库上调用delete操作,并假设一切正常向客户端返回OK

测试

为了测试,在 Postman 上对产品 ID 1 发送一个DELETE请求:

你会得到一个 200 OK 的响应。要检查它是否真的被删除,尝试对同一产品进行GET请求。你会得到一个空的响应。

缓存失效

如果进行填充缓存的获取操作,那么当进行PUT/POST/DELETE操作更新数据时,缓存要么更新,要么失效。

如果你还记得,我们有一个缓存,保存着与类别 ID 对应的产品。当我们使用为插入、更新和删除创建的 API 添加和移除产品时,缓存需要刷新。我们首选检查是否可以更新缓存条目。然而,拉取与缓存对应的类别的业务逻辑存在于数据库中(通过WHERE子句)。因此,最好在产品更新时使包含关系的缓存失效。

缓存使用情况的一般假设是读取远远高于插入和更新。

为了启用缓存驱逐,我们必须在ProductRepository类中添加方法并提供注释。因此,我们在接口中除了现有的findByCatId方法之外添加了两个新方法,并标记驱逐为 false:

public interface ProductRepository extends CrudRepository<Product, Integer> { 

   @Cacheable("productsByCategoryCache") 
   List<Product> findByCatId(int catId); 

   @CacheEvict(cacheNames="productsByCategoryCache", allEntries=true) 
   Product save(Product product); 

   @CacheEvict(cacheNames="productsByCategoryCache", allEntries=true) 
   void delete(Product product); 
} 

尽管前面的代码是有效的解决方案,但并不高效。它会清除整个缓存。我们的缓存可能有数百个类别,清除与插入、更新或删除产品无关的类别是不正确的。

我们可以更加智能地只清除与正在操作的类别相关的条目:

@CacheEvict(cacheNames="productsByCategoryCache", key = "#result?.catId") 
Product save(Product product); 

@CacheEvict(cacheNames="productsByCategoryCache", key = "#p0.catId") 
void delete(Product product); 

由于Spring 表达式语言SpEL)和CacheEvict的文档,代码有点晦涩:

  1. key表示我们要清除的缓存条目。

  2. #result表示返回结果。我们从中提取catId并用它来清除数据。

  3. #p0表示调用方法中的第一个参数。这是我们想要使用类别并删除对象的product对象。

为了测试缓存清除是否正常工作,启动服务和 Eureka,发送以下请求,并观察结果:

请求结果
http://localhost:8082/products?id=1获取与类别1对应的产品并将其缓存。SQL 将显示在输出日志中。
http://localhost:8082/products?id=1从缓存中获取产品。SQL 中没有更新条目。
POSThttp://localhost:8082/product添加{"name":"Mango","catId":1}作为application/json将新的芒果产品添加到数据库。
http://localhost:8082/products?id=1反映了新添加的芒果。SQL 表明数据已刷新。

验证和错误消息

到目前为止,我们一直在非常安全的领域中前行,假设一切都是顺利的。但并不是一切都会一直正确。有许多情景,例如:

  1. GETPUTDELETE请求的产品不存在。

  2. PUTPOST缺少关键信息,例如,没有产品名称或类别。

  3. 业务验证,例如产品,应属于已知类别,名称应超过 10 个字符。

  4. 提交的数据格式不正确,例如类别 ID 的字母数字混合,而预期只有整数。

而且这些还不是详尽无遗的。因此,当出现问题时,进行验证并返回适当的错误代码和消息是非常重要的。

格式验证

如果请求的请求体格式有错误(例如,无效的 JSON),那么 Spring 在到达方法之前就会抛出错误。

例如,对于POST请求到http://localhost:8082/product,如果提交的主体缺少逗号,例如{"id":1 "name":"Fuji Apples" "catId":1},那么返回的错误代码是400。这表示这是一个格式不正确的请求:

{ 
  "timestamp": 1483701698917, 
  "status": 400, 
  "error": "Bad Request", 
  "exception": "org.springframework.http.converter.HttpMessageNotReadableException", 
  "message": "Could not read document: Unexpected character ('"' (code 34)): was expecting comma to separate Object entriesn at ... 

同样,例如在 ID 中使用字母而不是数字,将会很早地被捕获。例如,http://localhost:8082/product/A将导致无法转换值错误:

数据验证

一些错误可以在实体级别捕获,如果它们是不允许的。例如,当我们已经将Product实体注释为以下内容时,没有提供产品描述:

@Column(nullable = false) 
private String name ; 

这将导致错误消息,尝试在请求中保存没有名称的产品,例如{"id":1, "catId":1}

服务器返回500内部服务器错误,并给出详细消息如下:

could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: 

这不是一个很清晰的消息返回给客户。因此,最好在前期捕获验证并向客户返回400错误代码。

业务验证

这通常会在代码中完成,因为它是特定于正在解决的功能或业务用例的。例如,在更新或删除产品之前检查产品。这是一个简单的基于代码的验证,如下所示:

@RequestMapping(value="/product/{id}", method = RequestMethod.DELETE) 
ResponseEntity<Product> deleteProduct(@PathVariable("id") int id) { 

   // First fetch an existing product and then delete it.  
   Product existingProduct = prodRepo.findOne(id);  
   if (existingProduct == null) { 
         return new ResponseEntity<Product>(HttpStatus.NOT_FOUND); 
   } 

   // Return the inserted product with status ok 
   prodRepo.delete(existingProduct); 
   return new ResponseEntity<Product>(HttpStatus.OK);           
} 

异常和错误消息

在出现错误的情况下,最简单的开始是指示一个错误消息,告诉我们出了什么问题,特别是在出现错误的输入请求或业务验证的情况下,因为客户端(或请求者)可能不知道出了什么问题。例如,在前面的情况下,返回NOT_FOUND状态码,但没有提供其他细节。

Spring 提供了有趣的注释,如ExceptionHandlerControllerAdvice来处理这个错误。让我们看看这是如何工作的。

其次,服务方法之前直接通过发送 HTTP 代码来操作ResponseEntity。我们将其恢复为返回业务对象,如Product,而不是ResponseEntity,使其更像 POJO。将之前讨论的deleteProduct代码恢复如下:

@RequestMapping(value="/product/{id}", method = RequestMethod.DELETE) 
Product deleteProduct(@PathVariable("id") int id) { 

   // First fetch an existing product and then delete it.  
   Product existingProduct = prodRepo.findOne(id);  
   if (existingProduct == null) { 
     String errMsg = "Product Not found with code " + id ;            
     throw new BadRequestException(BadRequestException.ID_NOT_FOUND, errMsg); 
   }      
   // Return the deleted product  
   prodRepo.delete(existingProduct); 
   return existingProduct ;             
} 

在上述代码中:

  1. 我们返回Product而不是ResponseEntity,因为处理错误代码和响应将在外部完成。

  2. 抛出异常(运行时异常或其扩展版本),告诉我们请求出了什么问题。

  3. Product方法的范围到此结束。

BadRequestException类是一个简单的类,提供了一个 ID,并继承自RuntimeException类。

public class BadRequestException extends RuntimeException { 

   public static final int ID_NOT_FOUND = 1 ;       
   private static final long serialVersionUID = 1L; 

   int errCode ; 

   public BadRequestException(int errCode, String msg) { 
         super(msg); 
         this.errCode = errCode ; 
   } 
} 

当您现在执行服务时,不仅会得到404 Not Found状态,还会得到一个明确指出出了什么问题的消息。查看发送的请求和收到的异常的截图:

然而,发送500并在日志中得到异常堆栈并不干净。500表明错误处理不够健壮,堆栈跟踪被抛出。

因此,我们应该捕获和处理这个错误。Spring 提供了@ExceptionHandler,可以在服务中使用。在方法上使用这个注解,Spring 就会调用这个方法来处理错误:

@ExceptionHandler(BadRequestException.class) 
void handleBadRequests(BadRequestException bre, HttpServletResponse response) throws IOException { 

   int respCode = (bre.errCode == BadRequestException.ID_NOT_FOUND) ? 
         HttpStatus.NOT_FOUND.value() : HttpStatus.BAD_REQUEST.value() ; 

   response.sendError(respCode, bre.errCode + ":" + bre.getMessage()); 
} 

当我们现在执行服务,并调用一个不可用的产品 ID 的DELETE方法时,错误代码变得更加具体和清晰:

现在,再进一步,如果我们希望所有的服务都遵循这种提出BadRequestException并返回正确的错误代码的模式呢?Spring 提供了一种称为ControllerAdvice的机制,当在一个类中使用时,该类中的异常处理程序可以普遍应用于范围内的所有服务。

创建一个新的类如下,并将其放在异常包中:

@ControllerAdvice 
public class GlobalControllerExceptionHandler { 

   @ExceptionHandler(BadRequestException.class) 
   void handleBadRequests(BadRequestException bre, HttpServletResponse response) throws IOException { 

         ... Same code as earlier ...  
   } 
} 

这允许异常以一致的方式在服务之间处理。

CQRS 的数据更新

如前一章讨论的,并且我们在前一节中看到的,CQRS 模式为处理命令和查询提供了高效和合适的数据模型。回顾一下,我们在 MongoDB 中有一个灵活的文档模型来处理具有事务保证的命令模式。我们在 Elasticsearch 中有一个灵活的查询模型来处理复杂的搜索条件。

尽管这种模式由于合适的查询模型而允许更容易的查询,但挑战在于跨各种模型更新数据。在前一章中,我们讨论了多种机制来保持信息在模型之间的更新,如分布式事务,使用发布-订阅消息的最终一致模型。

在接下来的章节中,我们将看看使用消息传递和异步更新数据的机制。

异步消息

HTTP/REST 提供了请求响应机制来执行服务。客户端等待(或者说是阻塞),直到处理完成并使用服务结束时提供的结果。因此,处理被称为同步的。

在异步处理中,客户端不等待响应。异步处理可以用于两种情况,如发送和忘记请求/响应

在“发送并忘记”中,客户端向下游服务发送命令或请求,然后不需要响应。它通常用于管道处理架构,其中一个服务对请求进行丰富和处理,然后将其发送到另一个服务,后者发送到第三个服务,依此类推。

在异步请求/响应中,客户端向服务发送请求,但与同步处理不同,它不会等待或阻塞响应。当服务完成处理时,必须通知客户端,以便客户端可以使用响应。

在 CQRS 中,我们使用消息传递将更新事件发送到各种服务,以便更新读取或查询模型。

首先,我们将在本章中使用 ActiveMQ 作为可靠的消息传递机制,然后在接下来的章节中查看 Kafka 作为可扩展的分布式消息传递系统。

启动 ActiveMQ

设置 ActiveMQ 的步骤如下:

  1. 从 Apache 网站(activemq.apache.org/)下载 ActiveMQ。

  2. 将其解压到一个文件夹中。

  3. 导航到bin文件夹。

  4. 运行activemq start命令。

打开控制台查看消息并管理 ActiveMQ,网址为http://localhost:8161/admin,使用admin/admin登录。您应该看到以下 UI 界面:

创建一个主题

点击“主题”链接,创建一个名为ProductT的主题。您可以按照您习惯的命名约定进行操作。此主题将获取产品的所有更新。这些更新可以用于各种下游处理目的,例如保持本地数据模型的最新状态。创建主题后,它将出现在管理控制台的主题列表中,如下所示。另外两个主题是 ActiveMQ 自己的主题,我们将不予理睬:

黄金源更新

当 CQRS 中有多个模型时,我们遵循之前讨论的黄金源模式:

  1. 一个模型(命令模型)被认为是黄金源。

  2. 在更新到黄金源之前进行所有验证。

  3. 对黄金源的更新发生在一个事务中,以避免任何不一致的更新和失败状态。因此,更新操作是自动的。

  4. 更新完成后,将在一个主题上放置广播消息。

  5. 如果在将消息放在主题上时出现错误,则事务将被回滚,并向客户端发送错误。

我们使用 MongoDB 和 Elasticsearch 进行了 CQRS 实现。在我们的情况下,MongoDB 是产品数据的黄金源(也是命令模型)。Elasticsearch 是包含从搜索角度组织的数据的查询模型。

首先让我们来看看更新命令模型或黄金源。

服务方法

我们在 HSQLDB 实现中做了三种方法:插入、更新和删除。将相同的方法复制到基于 MongoDB 的项目中,以便该项目中的服务类与 HSQLDB 项目中的完全相同。

此外,复制在 HSQLDB 项目中完成的异常类和ControllerAdvice。您的包结构应该与 HSQLDB 项目完全相同,如下所示:

在这个项目中的不同之处在于 ID 是一个字符串,因为这样可以更好地在 MongoDB 中进行 ID 创建的本地处理。因此,方法签名将是字符串 ID,而不是我们 HSQLDB 项目中的整数。

显示更新 MongoDB 的PUT操作如下:

@RequestMapping(value="/product/{id}", method = RequestMethod.PUT) 
Product updateProduct(@PathVariable("id") String id, @RequestBody Product product) { 

   // First fetch an existing product and then modify it.  
   Product existingProduct = prodRepo.findOne(id);  
   if (existingProduct == null) { 
         String errMsg = "Product Not found with code " + id ; 
         throw new BadRequestException(BadRequestException.ID_NOT_FOUND, errMsg); 
   } 

   // Now update it back  
   existingProduct.setCatId(product.getCatId()); 
   existingProduct.setName(product.getName()); 
   Product savedProduct = prodRepo.save(existingProduct) ; 

   // Return the updated product   
   return savedProduct ;          
} 

测试获取、插入、更新和删除操作是否按预期运行。

在数据更新时引发事件

当插入、删除或更新操作发生时,黄金源系统广播更改是很重要的,这样许多下游操作就可以发生。这包括:

  1. 依赖系统的缓存清除。

  2. 系统中本地数据模型的更新。

  3. 进一步进行业务处理,例如在添加新产品时向感兴趣的客户发送电子邮件。

使用 Spring JMSTemplate 发送消息

使用 JMSTemplate 的步骤如下:

  1. 在我们的 POM 文件中包含 Spring ActiveMQ 的启动器:
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-activemq</artifactId> 
        </dependency>
  1. 我们必须为我们的 Spring 应用程序启用 JMS 支持。因此,请在ProductSpringApp.java文件中包括注解,并提供消息转换器。消息转换器将帮助将对象转换为 JSON,反之亦然:
@SpringBootApplication 
@EnableDiscoveryClient 
@EnableJms 
public class ProductSpringApp {
  1. 创建一个封装Product和操作的实体,这样无论谁收到产品消息,都会知道执行的操作是删除还是插入/更新,通过在ProductUpdMsg.java文件中添加实体,如下所示:
public class ProductUpdMsg { 

   Product product ; 
   boolean isDelete = false ; 
// Constructor, getters and setters 

如果有更多操作,请随时根据您的用例将isDelete标志更改为字符串操作标志。

  1. application.properties文件中配置 JMS 属性。pub-sub-domain表示应使用主题而不是队列。请注意,默认情况下,消息是持久的:
spring.activemq.broker-url=tcp://localhost:61616 
jms.ProductTopic=ProductT 
spring.jms.pub-sub-domain=true 
  1. 创建一个消息生产者组件,它将负责发送消息:
  • 这是基于 Spring 的JmsMessagingTemplate

  • 使用JacksonJmsMessageConverter将对象转换为消息结构

ProductMsgProducer.java文件如下:

@Component 
public class ProductMsgProducer { 

   @Autowired  
   JmsTemplate prodUpdtemplate ; 

   @Value("${jms.ProductTopic}") 
   private String productTopic ; 

@Bean 
   public MessageConverter jacksonJmsMessageConverter() { 
         MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter(); 
         converter.setTargetType(MessageType.TEXT); 
         converter.setTypeIdPropertyName("_type"); 
         return converter; 

   public void sendUpdate(Product product, boolean isDelete) { 
         ProductUpdMsg msg = new ProductUpdMsg(product, isDelete);          
         prodUpdtemplate.convertAndSend(productTopic, msg);  
   }      
} 
  1. 最后,在您的服务中,声明producer,并在完成插入、更新和删除操作之后调用它,然后返回响应。DELETE方法如下所示,其中标志isDelete为 true。其他方法的标志将为 false。ProductService.java文件如下:
@Autowired 
ProductMsgProducer producer ; 

@RequestMapping(value="/product/{id}", method = RequestMethod.DELETE) 
Product deleteProduct(@PathVariable("id") String id) { 

   // First fetch an existing product and then delete it.  
   Product existingProduct = prodRepo.findOne(id);  
   if (existingProduct == null) { 
         String errMsg = "Product Not found with code " + id ;              
         throw new BadRequestException(BadRequestException.ID_NOT_FOUND, errMsg); 
   } 

   // Return the deleted product  
   prodRepo.delete(existingProduct); 
   producer.sendUpdate(existingProduct, true); 
   return existingProduct ;             
} 

这将在主题上发送消息,您可以在管理控制台的主题部分看到。

查询模型更新

productsearch项目中,我们将不得不进行更改以更新 Elasticsearch 中的记录。

插入、更新和删除方法

这些方法与我们在 MongoDB 中设计的方法非常不同。以下是区别:

  1. MongoDB 方法有严格的验证。对于 Elasticsearch,不需要验证,因为假定主服务器(命令模型或黄金源)已更新,我们必须将更新应用到查询模型中。

  2. 更新查询模型时的任何错误都必须得到警告,不应被忽视。我们将在后面的章节中看到这一方面。

  3. 我们不分开插入和更新方法。由于我们的ProductRepository类,单个保存方法就足够了。

  4. 此外,这些方法不必暴露为 REST HTTP 服务,因为除了通过消息更新之外,可能不会直接调用它们。我们之所以在这里这样做,只是为了方便。

  5. product-nosql(MongoDB)项目中,我们从ProductService类中调用了我们的ProductMsgProducer类。在productsearch-nosql项目中,情况将完全相反,ProductUpdListener将调用服务方法。

以下是更改:

  1. Maven POM—依赖于 ActiveMQ:
<dependency> 
   <groupId>org.springframework.boot</groupId> 
   <artifactId>spring-boot-starter-activemq</artifactId> 
</dependency> 
  1. 应用程序属性包括主题和连接详细信息:
spring.activemq.broker-url=tcp://localhost:61616 
jms.ProductTopic=ProductT 
spring.jms.pub-sub-domain=true
  1. Product服务包括调用存储库保存和删除方法:
   @PutMapping("/product/{id}") 
   public void insertUpdateProduct(@RequestBody Product product) {          
         prodRepo.save(product) ;                         
   } 

   @DeleteMapping("/product/{id}") 
   public void deleteProduct(@RequestBody Product product) { 
         prodRepo.delete(product); 
   } 

JMS 相关的类和更改如下:

  1. ProductSpringApp中,包括EnableJms注解,就像在 MongoDB 项目中一样。

  2. 创建一个调用服务的ProductUpdListener类:

@Component 
public class ProductUpdListener { 

   @Autowired 
   ProductService prodService ; 

   @JmsListener(destination = "${jms.ProductTopic}", subscription = "productSearchListener") 
   public void receiveMessage(ProductUpdMsg msg) { 

         Product product = msg.getProduct() ; 
         boolean isDelete = msg.isDelete() ; 
         if (isDelete) { 
               prodService.deleteProduct(product); 
               System.out.println("deleted " + product.getId()); 
         } else { 
               prodService.insertUpdateProduct(product);        
               System.out.println("upserted " + product.getId()); 
         } 
   } 

   @Bean // Serialize message content to json using TextMessage 
   public MessageConverter jacksonJmsMessageConverter() { 
         MappingJackson2MessageConverter converter = new  
         MappingJackson2MessageConverter(); 
         converter.setTargetType(MessageType.BYTES); 
         converter.setTypeIdPropertyName("_type"); 
         return converter; 
   } 
}  

测试 CQRS 更新场景端到端

为了测试我们的场景,请执行以下步骤:

  1. 在本地计算机上启动三个服务器进程,例如 Elasticsearch、MongoDB 和 ActiveMQ,如前面所讨论的。

  2. 启动 Eureka 服务器。

  3. 启动两个应用程序,一个连接到 MongoDB(黄金源,命令模型),监听8085,另一个连接到 Elasticsearch(查询模型),监听8086

  4. 在 Elasticsearch 上测试GET请求—http://localhost:8086/products?id=1,并注意 ID 和描述。

  5. 现在,通过在 Postman 上发出以下命令来更改黄金源上的产品描述,假设服务正在端口8085上监听:

  1. 再次在 Elasticsearch 上测试GET请求——http://localhost:8086/products?id=1。您会发现 Elasticsearch 中的产品描述已更新。

摘要

在本章中,我们涵盖了许多核心概念,从添加常规关系数据库来支持我们的 GET 请求开始。我们通过本地缓存和分布式缓存 Hazelcast 增强了其性能。我们还研究了 CQRS 模式,用 MongoDB 替换了我们的关系数据库,以实现灵活的模式和 Elasticsearch 的灵活搜索和查询功能。

我们为我们的product服务添加了插入、更新和删除操作,并确保在关系项目的情况下进行必要的缓存失效。我们为我们的 API 添加了输入验证和适当的错误消息。我们涵盖了事件处理,以确保查询模型与命令模型保持最新。这是通过命令模型服务发送更改的广播,以及查询模型服务监听更改并更新其数据模型来实现的。

接下来,我们将看看如何使这些项目足够健壮,以在运行时环境中运行。

第六章:测试云原生应用程序

在本章中,我们将深入探讨测试云原生应用程序。测试从手动测试发展到使用各种测试工具、策略和模式进行自动化测试。这种方法的好处是可以频繁地进行测试,以确保云开发的重要性。

在本章中,我们将涵盖以下主题:

  • 测试概念,如行为驱动开发(BDD)测试驱动开发(TDD)

  • 测试模式,如 A/B 测试和测试替身

  • 测试工具,如 JUnit,Cucumber,JaCoCo 和 Spring Test

  • 测试类型,如单元测试、集成测试、性能测试和压力测试

  • 将 BDD 和集成测试的概念应用到我们在第二章中开发的产品服务,并在第四章中进行了增强,扩展您的云原生应用程序

在开发之前编写测试用例

在本书中,我们在第二章中开始使用 Spring Boot 开发一个简单的服务,编写您的第一个云原生应用程序,以激发您对云开发的兴趣。然而,真正的开发遵循不同的最佳实践风格。

TDD

项目始于理解需求并编写验证需求的测试用例。由于此时代码不存在,测试用例将失败。然后编写通过测试用例的代码。这个过程迭代直到测试用例和所需的代码完成以实现业务功能。Kent Beck 在这个主题上有一本优秀的书,通过示例进行测试驱动开发。在下一节中,我们将使用本章的原则重新进行第四章中的产品服务。但在此之前,让我们看看另一个重要概念,BDD。

BDD

借鉴敏捷开发原则和用户故事,BDD 鼓励我们将开发看作一系列场景,在这些场景中,给定某些条件,系统对设置的刺激以一种特定、可预测的方式做出反应。如果这些场景、条件和行为可以用业务和 IT 团队之间易于理解的共同语言来表达,这将为开发带来很多清晰度,并减少犯错的机会。这是一种编写易于测试的规范的方法。

在本章中,我们将采用 Cucumber 工具对我们的产品服务应用 BDD。

测试模式

为云端测试大型互联网应用程序需要一个有纪律的方法,其中一些模式非常有用。

A/B 测试

A/B 测试的最初目的,也称为分割测试,是为了通过实验找出少数选定用户对具有相同功能的两个不同网页的用户响应。如果用户对某种模式的响应比其他模式更好,那么就选择该模式。

这个概念可以扩展到分阶段引入新功能。功能、活动、布局或新服务被引入到一组受控的用户中,并且对其响应进行测量:

测试窗口结束后,结果被汇总以规划更新功能的有效性。

这种测试的策略是对于选定的用户组,使用 HTTP 302(临时重定向)将用户从常规网站切换到新设计的网站。这将需要在测试期间运行网站或功能服务的变体。一旦测试成功,该功能将逐渐扩展到更多用户,并合并到主网站/代码库中。

测试替身

通常,受测试的功能依赖于由其他团队独立开发的组件和 API,这具有以下缺点:

  • 它们可能在开发功能时无法进行测试

  • 它们可能并不总是可用,并且需要设置所需的数据来测试各种情况

  • 每次使用实际组件可能会更慢

因此,测试替身的概念变得流行。测试替身(就像电影中的替身演员)是一个替换实际组件并模仿其行为的组件/ API。测试替身组件通常是一个轻量级且易于更改的组件,由构建功能的团队控制,而不像可能是依赖项或外部进程的真实组件。

有许多类型的测试替身,例如虚拟、伪装、测试桩和模拟。

测试桩

当下游组件返回改变系统行为的响应时,测试桩非常有用;例如,如果我们的产品服务要调用一个决定产品服务行为的参考数据服务。参考数据服务的测试桩可以模仿导致产品服务行为改变的各种响应类型:

模拟对象

下一个测试替身类型是模拟对象,它记录系统如何与其行为,并将记录呈现以进行验证。例如,模拟数据库组件可以检查是否应该从缓存层而不是数据库中调用产品。

以下是关于模拟的生态系统的基本图表表示:

模拟 API

在云开发中,您将构建一个依赖于其他服务或主要通过这些服务访问的 API 的服务。通常,其他服务将无法立即进行测试。但您不能停止开发。这就是模拟或添加虚拟服务的有用模式来测试您的服务的地方。

服务模拟模拟了真实服务的所有合同和行为。一些示例,如WireMock.orgMockable.io,帮助我们模拟 API 并测试主要情况、边缘情况和故障情况。

确保代码审查和覆盖率

通过自动代码审查工具来增强对代码的手动审查。这有助于识别代码中可能的错误,并确保覆盖完整并测试所有路径。

我们稍后将看一下代码覆盖工具 JaCoCo。

测试类型

我们稍后在本章中讨论的各种测试类型在云计算变得流行之前就已经被了解。使用持续集成CI)和持续开发CD)的敏捷开发原则使得自动化这些测试类型变得重要,以便它们在每次代码检入和构建发生时执行。

单元测试

单元测试的目的是测试每个类或代码组件,并确保其按预期执行。JUnit 是流行的 Java 单元测试框架。

使用模拟对象模式和测试桩,可以隔离正在测试的服务的依赖组件,以便测试集中在系统正在测试的系统上。

JUnit 是执行单元测试的最流行的工具。

集成测试

组件测试的目的是检查组件(如产品服务)是否按预期执行。

诸如spring-boot-test之类的组件有助于运行测试套件并对整个组件进行测试。我们将在本章中看到这一点。

负载测试

负载测试涉及向系统发送大量并发请求一段时间,并观察其影响,如系统的响应时间和错误率。如果添加更多服务实例使系统能够处理额外的负载,则称系统具有水平可扩展性。

JMeter 和 Gatling 是流行的工具,用于覆盖这个维度。

回归测试

在引入新功能时,现有功能不应该中断。回归测试可以覆盖这一点。

Selenium 是一个基于 Web 浏览器的开源工具,在这个领域很受欢迎,用于执行回归测试。

测试产品服务

让我们将我们学到的测试原则应用于迄今为止构建的产品服务。我们从用户的角度开始,因此从验收测试开始。

通过 Cucumber 进行 BDD

第一步是回顾我们产品服务的规范。在第四章中,扩展您的云原生应用,我们构建了一些关于产品服务的功能,允许我们获取、添加、修改和删除产品,并在给定产品类别的情况下获取产品 ID 列表。

让我们在 Cucumber 中表示这些特性。

为什么选择 Cucumber?

Cucumber 允许用一种类似于普通英语的语言Gherkin表达行为。这使得领域驱动设计术语中的通用语言成为可能,从而使业务、开发和测试之间的沟通变得无缝和易于理解。

Cucumber 是如何工作的?

让我们了解一下 Cucumber 是如何工作的:

  1. Cucumber 的第一步是将用户故事表达为具有场景和Given-When-Then条件的特性:
  • 给定:为行为设置前提条件

  • 当:触发改变系统状态的操作,例如向服务发出请求

  • 然后:服务应该如何响应

  1. 这些被翻译为自动化测试用例,使用cucumber-spring翻译层,以便可以执行。

让我们从一个简单的getProduct验收测试用例开始。我们将用 Gherkin 编写一个简单的特性,如果产品 ID 存在,则获取产品,如果找不到产品 ID,则返回错误。

让我们以真正的 BDD 风格实现以下功能。产品服务上的“获取”API 返回产品细节,例如描述和类别 ID,给定产品 ID。如果找不到产品,它也可以返回错误,例如 404。让我们将这两种行为表示为我们的 Gherkin 特性文件上的两个独立场景。

特性:“获取产品”

获取产品 ID 的产品细节。

场景 1:产品 ID 有效且存在。将返回产品名称和所属类别:

  1. 给定产品服务正在运行

  2. 当使用现有产品 ID 1 调用获取产品服务时

  3. 那么我们应该得到一个带有 HTTP 状态码 200 的响应

  4. 并返回产品细节,名称为“苹果”,类别为1

场景 2:产品 ID 无效或不存在。应返回错误:

  1. 给定产品服务正在运行

  2. 当使用不存在的产品 ID 456 调用获取产品服务时

  3. 然后返回 404 未找到状态

  4. 并返回错误消息“ID 456 没有产品”

场景 1 是一个成功的场景,其中返回并验证了数据库中存在的产品 ID。

场景 2 检查数据库中不存在的 ID 的失败情况。

每个场景分为多个部分。对于正常路径场景:

  • 给定设置了一个前提条件。在我们的情况下,这很简单:产品服务应该正在运行。

  • 当改变系统的状态时,在我们的情况下,是通过提供产品 ID 向服务发出请求。

  • 然后和并是系统预期的结果。在这种情况下,我们期望服务返回 200 成功代码,并为给定产品返回有效的描述和类别代码。

正如您可能已经注意到的,这是我们的服务的文档,可以被业务和测试团队以及开发人员理解。它是技术无关的;也就是说,如果通过 Spring Boot、Ruby 或.NET 微服务进行实现,它不会改变。

在下一节中,我们将映射到我们开发的 Spring Boot 应用程序的服务。

使用 JaCoCo 进行代码覆盖

JaCoCo 是由 EclEmma 团队开发的代码覆盖库。JaCoCo 在 JVM 中嵌入代理,扫描遍历的代码路径并创建报告。

此报告可以导入更广泛的 DevOps 代码质量工具,如 SonarQube。SonarQube 是一个平台,帮助管理代码质量,具有众多插件,并与 DevOps 流程很好地集成(我们将在后面的章节中看到)。它是开源的,但也有商业版本。它是一个平台,因为它有多个组件,如服务器(计算引擎服务器、Web 服务器和 Elasticsearch)、数据库和特定于语言的扫描器。

Spring Boot 测试

Spring Boot 测试扩展并简化了 Spring 框架提供的 Spring-test 模块。让我们看一下编写我们的验收测试的基本要素,然后我们可以在本章后面重新讨论细节:

  1. 将我们在第四章中创建的项目,使用 HSQLDB 和 Hazelcast 扩展您的云原生应用,复制为本章的新项目。

  2. 在 Maven POM 文件中包含 Spring 的依赖项:

        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-test</artifactId> 
            <scope>test</scope> 
        </dependency> 

正如您可能已经注意到的,scope已更改为test。这意味着我们定义的依赖项不是正常运行时所需的,只是用于编译和测试执行。

  1. 向 Maven 添加另外两个依赖项。我们正在下载 Cucumber 及其 Java 翻译的库,以及spring-boot-starter-test
        <dependency> 
            <groupId>info.cukes</groupId> 
            <artifactId>cucumber-spring</artifactId> 
            <version>1.2.5</version> 
            <scope>test</scope> 
        </dependency> 
        <dependency> 
            <groupId>info.cukes</groupId> 
            <artifactId>cucumber-junit</artifactId> 
            <version>1.2.5</version> 
            <scope>test</scope> 
        </dependency> 

CucumberTest类是启动 Cucumber 测试的主类:

@RunWith(Cucumber.class) 
@CucumberOptions(features = "src/test/resources") 
public class CucumberTest { 

} 

RunWith告诉 JUnit 使用 Spring 的测试支持,然后使用 Cucumber。我们给出我们的.feature文件的路径,其中包含了前面讨论的 Gherkin 中的测试用例。

Productservice.feature文件是以 Gherkin 语言编写的包含场景的文本文件,如前所述。我们将在这里展示两个测试用例。该文件位于src/test/resources文件夹中。

CucumberTestSteps类包含了从 Gherkin 步骤到等效 Java 代码的翻译。每个步骤对应一个方法,方法根据 Gherkin 文件中的场景构造而被调用。让我们讨论与一个用例相关的所有步骤:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 
@ContextConfiguration 
public class CucumberTestSteps { 

    @Autowired 
    private TestRestTemplate restTemplate; 

    private ResponseEntity<Product> productResponse; 
    private ResponseEntity<String> errResponse; 

    @Given("(.*) Service is running") 
    public void checkServiceRunning(String serviceName) { 
         ResponseEntity<String> healthResponse = restTemplate.getForEntity("/health",String.class, new HashMap<>()); 
         Assert.assertEquals(HttpStatus.OK, healthResponse.getStatusCode()); 
    } 

    @When("get (.*) service is called with existing product id (\d+)$") 
    public void callService(String serviceName, int prodId) throws Throwable { 
         productResponse = this.restTemplate.getForEntity("/"+serviceName+"/" + prodId, Product.class, new HashMap<>()); 
    } 

    @Then("I should get a response with HTTP status code (.*)") 
    public void shouldGetResponseWithHttpStatusCode(int statusCode) { 
         Assert.assertEquals(statusCode, productResponse.getStatusCodeValue()); 
    } 

    @And("return Product details with name (.*) and category (\d+)$") 
    public void theResponseShouldContainTheMessage(String prodName, int categoryId) { 
         Product product = productResponse.getBody() ; 
         Assert.assertEquals(prodName, product.getName()); 
         Assert.assertEquals(categoryId, product.getCatId());       
    } 

@SpringBootTest注解告诉 Spring Boot 框架这是一个测试类。RANDOM_PORT表示测试服务在随机端口上启动 Tomcat 进行测试。

我们注入一个自动装配的restTemplate,它将帮助访问 HTTP/REST 服务并接收将被测试的响应。

现在,请注意带有注释@Given@When@Then的方法。每个方法使用正则表达式从特性文件中提取变量,并在方法中用于断言。我们已经通过以下方式系统地测试了这一点:

  1. 首先通过访问/health(就像我们在第二章中为 Spring Boot 执行器所做的那样)检查服务是否正在运行。

  2. 使用产品 ID 调用服务。

  3. 检查返回代码是否为200,并且响应的描述和类别是否与预期结果匹配。

  4. 运行测试。

  5. 右键单击CucumberTest.java文件,选择 Run As | JUnit Test:

您将看到控制台启动并显示启动消息。最后,JUnit 将反映测试结果如下:

作为练习,尝试向ProductService类中的插入、更新和删除产品方法添加测试用例。

集成 JaCoCo

让我们将 JaCoCo 集成到我们现有的项目中:

  1. 首先,在 POM 文件中包含包含 JaCoCo 的插件:
<plugin> 
   <groupId>org.jacoco</groupId> 
   <artifactId>jacoco-maven-plugin</artifactId> 
   <version>0.7.9</version> 
</plugin> 

第二步和第三步是将前置执行和后置执行包含到前面的插件中。

  1. 预执行准备代理配置并添加到命令行。

  2. 后置执行确保报告在输出文件夹中创建:

<executions> 
   <execution> 
         <id>pre-unit-test</id> 
         <goals> 
               <goal>prepare-agent</goal> 
         </goals> 
         <configuration> 
               <destFile>${project.build.directory}/coverage-reports/jacoco-ut.exec</destFile> 
               <propertyName>surefireArgLine</propertyName> 
         </configuration> 
   </execution> 
   <execution> 
         <id>post-unit-test</id> 
         <phase>test</phase> 
         <goals> 
               <goal>report</goal> 
         </goals> 
         <configuration> 
               <dataFile>${project.build.directory}/coverage-reports/jacoco-ut.exec</dataFile> 
   <outputDirectory>${project.reporting.outputDirectory}/jacoco-ut</outputDirectory> 
         </configuration> 
   </execution> 
</executions> 
  1. 最后,创建的命令行更改必须插入到maven-surefire-plugin中,如下所示:
<plugin> 
   <groupId>org.apache.maven.plugins</groupId> 
   <artifactId>maven-surefire-plugin</artifactId> 
   <configuration> 
         <!-- Sets the VM argument line used when unit tests are run. --> 
         <argLine>${surefireArgLine}</argLine> 
         <excludes> 
               <exclude>**/IT*.java</exclude> 
         </excludes>        
   </configuration> 
</plugin> 
  1. 现在,我们已经准备好运行覆盖报告了。右键单击项目,选择 Run As | Maven test 来测试程序,如下面的截图所示:

  1. 随着控制台填满 Spring Boot 的启动,您会发现以下行:
2 Scenarios ([32m2 passed[0m) 
8 Steps ([32m8 passed[0m) 
0m0.723s 
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 26.552 sec - in com.mycompany.product.CucumberTest......Results :Tests run: 10, Failures: 0, Errors: 0, Skipped: 0[INFO] [INFO] --- jacoco-maven-plugin:0.7.9:report (post-unit-test) @ product ---[INFO] Loading execution data file D:AppswkNeonch5-producttargetcoverage-reportsjacoco-ut.exec[INFO] Analyzed bundle 'product' with 6 classes 
  1. 这告诉我们有两种情况执行了8 步(与之前一样)。但另外,coverage-reports也生成并放置在target目录中:

  1. site文件夹中,点击index.html;您将看到覆盖报告如下:

  1. 在调查product包时,您可以看到ProductService只覆盖了24%,如下面的截图所示:

  1. 原因是我们只覆盖了服务中的getProduct API。insertProductupdateProduct没有被覆盖。这在下面的钻取报告中展示:

  1. getProduct方法上,覆盖率是完整的。这是因为在两种情况下,我们已经覆盖了正常路径以及错误条件:

  1. 另一方面,您会发现我们错过了ExceptionHandler类中的分支覆盖,如下所示:

摘要

在接下来的章节中,我们将把覆盖报告与 DevOps 管道集成,并在 CI 和 CD 期间看到它的工作。但首先,让我们看一下部署机制。

第七章:云原生应用部署

云原生应用最独特的一点是它们的部署方式。在传统的应用部署中,团队通过登录服务器并安装应用程序来部署他们的应用。但在云中通常有许多服务器,登录到每台服务器并手动安装应用程序是不可行的,而且可能非常容易出错。为了解决这些问题,我们使用云配置工具来自动部署云原生应用。

在本章中,我们将深入探讨微服务的部署模型,包括如何将应用程序打包为 Docker 容器、如何设置 CI/CD 流水线以及如何保护您的服务免受分布式拒绝服务(DDoS)等安全攻击。我们将涵盖以下内容:

  • 部署模型、打包和容器化(使用 Docker)

  • 部署模式(蓝绿部署、金丝雀发布和暗部署)

  • DDoS

  • CI/CD

部署模型

我们将涵盖在云环境中部署我们的应用程序所使用的部署模型。

虚拟化

云的基本构建块是虚拟机(从现在开始称为 VM),它相当于用户可以登录并安装或维护应用程序的物理服务器(或主机)。不同之处在于可以在单个主机上托管多个 VM,从而增加资源利用率。这是通过使用虚拟化实现的,其中在主机上安装了一个可以将物理服务器上可用的资源(如计算、内存、存储和网络)分配给托管在其上的不同 VM 的 hypervisor。云原生应用可以使用以下策略部署在这些 VM 上:

  • 每个 VM 上有多个应用程序

  • 每个 VM 上一个应用程序

在每个 VM 上运行多个应用程序时,有可能一个应用程序占用了 VM 上所有可用的资源,使其他应用程序无法运行。另一方面,每个 VM 上只运行一个应用程序可以确保应用程序被隔离,以便它们不会相互影响,但这种部署方式的缺点是资源的浪费,因为每个应用程序可能并不总是消耗所有可用的资源。

PaaS

PaaS 或平台即服务是部署云原生应用的另一个流行选项。PaaS 提供了补充开发、扩展和维护云原生应用的附加服务。通过构建包,自动化构建和部署等服务大大减少了设置额外基础设施来支持这些活动所需的时间。PaaS 还提供了一些基本的基础设施服务,如监控、日志聚合、秘密管理和负载均衡。Cloud Foundry、Google App Engine、Heroku 和 OpenShift 是 PaaS 的一些示例。

容器

为了提供所需的独立运行级别的隔离,并节约资源利用,人们开发了容器技术。通过利用 Linux 内核的特性,容器在进程级别提供了 CPU、内存、存储和网络隔离。下图展示了虚拟化的差异:

容器消除了对客户操作系统的需求,因此大大增加了可以在同一主机上运行的容器数量,与可以在同一主机上运行的虚拟机数量相比。容器的占用空间也更小,大约为 MB 级别,而虚拟机很容易超过几 GB。

容器在 CPU 和内存方面也非常高效,因为它们不必支持运行完整操作系统时必须支持的许多外围系统:

前面的图表显示了云原生应用部署策略的演变,旨在增加资源利用率和应用程序的隔离性。在堆栈的顶部是在主机上运行的 VM 中运行的容器。这允许应用程序按两个程度进行扩展:

  • 增加 VM 中容器的数量

  • 增加运行容器的 VM 数量

Docker

Docker 是一个备受欢迎的容器运行时平台,已经证明自己是部署云原生应用程序的强大平台。Docker 在 Windows、Mac 和 Linux 等所有主要平台上都可用。由于容器需要 Linux 内核,因此在 Linux 环境中更容易运行 Docker 引擎。但是,在 Windows 和 Mac 环境中有多种资源可用于舒适地运行 Docker 容器。我们将演示如何将我们迄今为止开发的服务部署为 Docker 容器,包括连接到在其自己的容器中运行的外部数据库。

在我们的示例中,我们将使用 Docker Toolbox 并使用 Docker Machine 创建一个 VM,在其中将运行 Docker 引擎。我们将使用 Docker 命令行客户端连接到此引擎,并使用提供的各种命令。

构建 Docker 镜像

我们将开始将我们当前的项目作为一组 Docker 容器进行容器化。我们将逐步介绍每个项目的步骤。

Eureka 服务器

  1. $WORKSPACE/eureka-server/.dockerignore中添加一个.dockerignore文件,内容如下:
.* 
target/* 
!target/eureka-server-*.jar 
  1. $WORKSPACE/eureka-server/Dockerfile中添加一个包含以下内容的 Dockerfile:
FROM openjdk:8-jdk-alpine 

RUN mkdir -p /app 

ADD target/eureka-server-0.0.1-SNAPSHOT.jar /app/app.jar 

EXPOSE 8761 

ENTRYPOINT [ "/usr/bin/java", "-jar", "/app/app.jar" ] 
  1. 构建可运行的 JAR,将在目标文件夹中可用:
mvn package 
  1. 构建 Docker 容器:
docker build -t cloudnativejava/eureka-server . 

上一个命令的输出如下截图所示:

  1. 在运行容器之前,我们需要创建一个网络,不同的容器可以在其中自由通信。可以通过运行以下命令来创建这个网络:
docker network create app_nw 

上一个命令的输出如下截图所示:

  1. 使用名称eureka运行容器,并将其附加到之前创建的网络:
docker run -d --network app_nw --name eureka cloudnativejava/eureka-server 

上一个命令的输出如下截图所示:

产品 API

接下来我们将在产品 API 项目上进行工作:

  1. 通过将以下内容附加到现有文件中,在application.yml中添加一个新的 Spring 配置文件docker
--- 
spring: 
  profiles: docker 
eureka: 
  instance: 
    preferIpAddress: true 
  client: 
    serviceUrl: 
      defaultZone: http://eureka:8761/eureka/ 
  1. 构建 Spring Boot JAR 以反映对application.yml的更改:
mvn clean package 
  1. 添加一个.dockerignore文件,内容如下:
.* 
target/* 
!target/product-*.jar 
  1. 添加一个包含以下内容的 Dockerfile:
FROM openjdk:8-jdk-alpine 

RUN mkdir -p /app 

ADD target/product-0.0.1-SNAPSHOT.jar /app/app.jar 

EXPOSE 8080 

ENTRYPOINT [ "/usr/bin/java", "-jar", "/app/app.jar", "--spring.profiles.active=docker" ] 
  1. 构建 Docker 容器:
docker build -t cloudnativejava/product-api . 

上一个命令的输出如下截图所示:

  1. 启动多个 Docker 容器:
docker run -d -p 8011:8080 \ 
    --network app_nw \ 
    cloudnativejava/product-api 

docker run -d -p 8012:8080 \ 
    --network app_nw \ 
    cloudnativejava/product-api 

上一个命令的输出如下截图所示:

产品 API 将在以下 URL 上可用:

  • http://<docker-host>:8011/product/1

  • http://<docker-host>:8012/product/1

连接到外部 Postgres 容器

为了将product API 连接到外部数据库而不是内存数据库,首先创建一个包含数据的容器镜像:

  1. 创建一个文件import-postgres.sql,内容如下:
create table product(id serial primary key, name varchar(20), cat_id int not null); 
begin; 
insert into product(name, cat_id) values ('Apples', 1); 
insert into product(name, cat_id) values ('Oranges', 1); 
insert into product(name, cat_id) values ('Bananas', 1); 
insert into product(name, cat_id) values ('Carrots', 2); 
insert into product(name, cat_id) values ('Beans', 2); 
insert into product(name, cat_id) values ('Peas', 2); 
commit; 
  1. 创建一个包含以下内容的Dockerfile.postgres
FROM postgres:alpine 

ENV POSTGRES_USER=dbuser  
    POSTGRES_PASSWORD=dbpass  
    POSTGRES_DB=product 

EXPOSE 5432 

RUN mkdir -p /docker-entrypoint-initdb.d 

ADD import-postgres.sql /docker-entrypoint-initdb.d/import.sql 
  1. 现在构建包含数据库初始化内容的 Postgres 容器镜像:
docker build -t cloudnativejava/datastore -f Dockerfile.postgres . 

上一个命令的输出如下截图所示:

  1. 通过将以下内容附加到现有文件中,在application.yml中添加一个新的 Spring 配置文件postgres
--- 
spring: 
  profiles: postgres 
  datasource: 
    url: jdbc:postgresql://<docker-host>:5432/product 
    username: dbuser 
    password: dbpass 
    driver-class-name: org.postgresql.Driver 
  jpa: 
    database-platform: org.hibernate.dialect.PostgreSQLDialect 
    hibernate: 
      ddl-auto: none 

确保将<docker-host>替换为适合您环境的值。

  1. 构建 Spring Boot JAR 以反映对application.yml的更改:
mvn clean package 
  1. 构建 Docker 容器:
docker build -t cloudnativejava/product-api . 

上述命令的输出如下截图所示:

  1. 如果您已经有容器在旧镜像上运行,可以停止并删除它们:
old_ids=$(docker ps -f ancestor=cloudnativejava/product-api -q) 
docker stop $old_ids 
docker rm $old_ids 
  1. 启动数据库容器:
docker run -d -p 5432:5432  
    --network app_nw  
    --name datastore  
    cloudnativejava/datastore 

上述命令的输出如下截图所示:

  1. 启动几个产品 API 的 Docker 容器:
docker run -d -p 8011:8080  
    --network app_nw  
    cloudnativejava/product-api  
    --spring.profiles.active=postgres 

docker run -d -p 8012:8080  
    --network app_nw  
    cloudnativejava/product-api  
    --spring.profiles.active=postgres 

上述命令的输出如下截图所示:

产品 API 将在以下 URL 上可用:

  • http://<docker-host>:8011/product/1

  • http://<docker-host>:8012/product/1

部署模式

在介绍了云原生应用程序的打包和部署模型之后,我们将介绍用于部署云原生应用程序的模式。传统上,应用程序在多个环境中部署,如开发、测试、暂存、预生产等,每个环境可能是最终生产环境的缩减版本。应用程序通过一系列预生产环境,并最终部署到生产环境。然而,一个重要的区别是,虽然其他环境中容忍停机时间,但在生产部署中的停机时间可能导致严重的业务后果。

使用云原生应用程序,可以实现零停机发布软件。这是通过对开发、测试和部署的每个方面严格应用自动化来实现的。我们将在后面的部分介绍持续集成CI)/ 持续部署CD),但在这里我们将介绍一些能够快速部署应用程序的模式。所有这些模式都依赖于路由器组件的存在,它类似于负载均衡器,可以将请求路由到一定数量的应用实例。在某些情况下,应用程序本身构建了隐藏在功能标志后面的功能,可以通过对应用程序配置的更改来启用。

蓝绿部署

蓝绿部署是一个分为三个阶段的模式。部署的初始状态如下图所示。所有应用流量都路由到现有实例,这些实例被视为蓝色实例。蓝绿部署的表示如下:

在蓝绿部署的第一阶段,使用新版本的应用程序的一组新实例被配置并变为可用。在这个阶段,新的绿色应用实例对最终用户不可用,并且部署在内部进行验证。如下所示:

在部署的下一个阶段,路由器上会打开一个象征性的开关,现在开始将所有请求路由到绿色实例,而不是旧的蓝色实例。旧的蓝色实例会保留一段时间进行观察,如果检测到任何关键问题,我们可以根据需要快速回滚部署到旧的应用实例:

在部署的最后阶段,应用的旧蓝色实例被废弃,绿色实例成为下一个稳定的生产版本:

蓝绿部署在切换两个稳定版本的应用程序之间以及通过备用环境确保快速恢复时非常有效。

金丝雀部署

金丝雀部署也是蓝绿部署的一种变体。金丝雀部署解决了同时运行两个生产实例时浪费资源的问题,尽管时间很短。在金丝雀部署中,绿色环境是蓝色环境的缩减版本,并且依赖路由器的能力,始终将一小部分请求路由到新的绿色环境,而大部分请求则路由到蓝色环境。以下图表描述了这一点:

当发布应用程序的新功能需要与一些测试用户进行测试,然后根据这些用户群的反馈进行全面发布时,这种方法尤其有用。一旦确定绿色环境准备好全面发布,绿色环境的实例将增加,同时蓝色环境的实例将减少。以下是一系列图表的最佳说明:

这样就避免了运行两个生产级环境的问题,并且在从一个版本平稳过渡到另一个版本的同时,还提供了回退到旧版本的便利。

暗部署

另一种常用的部署模式是暗部署模式,用于部署云原生应用程序。在这种模式下,新功能被隐藏在功能标志后,并且仅对一组特定用户启用,或者在某些情况下,用户完全不知道该功能,而应用程序模拟用户行为并执行应用程序的隐藏功能。一旦确定功能准备好并且稳定可供所有用户使用,就通过切换功能标志来启用它。

应用 CI/CD 进行自动化

云原生应用程序部署的核心方面之一在于能够有效地自动化和构建软件交付流水线。这主要是通过使用能够从源代码库获取源代码、运行测试、构建可部署构件并将其部署到目标环境的 CI/CD 工具来实现的。大多数现代的 CI/CD 工具,如 Jenkins,都支持配置构建流水线,可以根据脚本形式的配置文件构建多个构件。

我们将以 Jenkins 流水线脚本为例,演示如何配置一个简单的构建流水线。在我们的示例中,我们将简单构建两个构件,即eureka-serverproduct-api可运行的 JAR 包。添加一个名为Jenkinsfile的新文件,内容如下:

node { 
  def mvnHome 
  stage('Preparation') { // for display purposes 
    // Get some code from a GitHub repository 
    git 'https://github.com/...' 
    // Get the Maven tool. 
    // ** NOTE: This 'M3' Maven tool must be configured 
    // **       in the global configuration. 
    mvnHome = tool 'M3' 
  } 
  stage('Eureka Server') { 
    dir('eureka-server') { 
      stage('Build - Eureka Server') { 
        // Run the maven build 
        if (isUnix()) { 
          sh "'${mvnHome}/bin/mvn' -Dmaven.test.failure.ignore clean package" 
        } else { 
          bat(/"${mvnHome}binmvn" -Dmaven.test.failure.ignore clean package/) 
        } 
      } 
      stage('Results - Eureka Server') { 
        archiveArtifacts 'target/*.jar' 
      } 
    }    
  } 
  stage('Product API') { 
    dir('product') { 
      stage('Build - Product API') { 
        // Run the maven build 
        if (isUnix()) { 
          sh "'${mvnHome}/bin/mvn' -Dmaven.test.failure.ignore clean package" 
        } else { 
          bat(/"${mvnHome}binmvn" -Dmaven.test.failure.ignore clean package/) 
        } 
      } 
      stage('Results - Product API') { 
        junit '**/target/surefire-reports/TEST-*.xml' 
        archiveArtifacts 'target/*.jar' 
      } 
    } 
  } 
} 

流水线脚本的功能如下:

  1. 从 GitHub 检出源代码

  2. 配置 Maven 工具

  3. 通过在检出的源代码库的两个目录中运行 Maven 构建来构建两个构件

  4. 存储构建的测试结果和结果 JAR 包

在 Jenkins 中创建一个新的流水线作业:

在流水线配置中,指定 GitHub 仓库和 Git 仓库中Jenkinsfile的路径:

运行构建后,应该会构建出两个构件:

可以通过扩展流水线脚本来构建我们在本章中手动构建的 Docker 容器,使用 Jenkins 的 Docker 插件。

总结

在本章中,我们了解了可以用于部署云原生应用程序的各种部署模式,以及如何使用 Jenkins 等持续集成工具来自动化构建和部署。我们还学习了如何使用 Docker 容器构建和运行示例云原生应用程序。