构建 Spring 服务
[免责声明:本文将深入探讨一种可能的微服务架构类型的诸多方面,并使用 Java 和 SpringBoot 进行示例展示。本文不会展示完整且可运行的 SpringBoot 设置,并且假设基本原则可以应用于任何技术栈。从架构的角度来看,基础思想才是最重要的。]
介绍
微服务最好被描述为一种架构风格,它将应用程序构建为具有以下特征的服务集合:
- 松散耦合;
- 高度可维护且易于测试;
- 由一个小团队拥有;
- 围绕商业概念构建;
- 允许结构化和独立部署;
这种架构风格近年来越来越受到人们的关注,许多公司在其技术博客上分享了这种架构模式的经验(例如Uber),许多白皮书也称赞了这种架构模式与传统整体式方法相比在可扩展性、易于开发和适应不断变化的需求方面的优势。
本文将尝试通过一个真实案例,讲解如何使用 Java 和 SpringBoot 构建微服务架构,并在必要时进行一些补充,例如解释特定的 Spring 概念或抽象,或者阐述示例架构如何与该模式本身的理念相契合。这一点至关重要,因为它为读者提供了一个真实的参考框架,让他们了解真实架构与该模式理想化的理论基础之间的差异。
理论与实践之间的差异正是乐趣所在,理解像 Spring 这样的框架中的一些基本原则如何真正促进某些类型的开发,从不同的角度看待事物会大有裨益。在开始之前,我们先来看看为什么使用微服务是一个好主意。
在现代世界中构建微服务
如今,一切都节奏飞快,日新月异,代码也不例外。事实上,根据 ArsTechnica 的一篇文章,开发人员声称他们现在管理的代码量比 2010 年增加了 100 倍。这是一个巨大的增长,而且这种趋势还在持续!
随着越来越多的公司加入数字化转型的浪潮,我们现在看到,一些公司甚至在主要业务并非技术的情况下,也出现了开发人员和开发人员的职位:零售、保险甚至食品行业,如今都在为如今的代码足迹做出贡献。
这意味着,原始的单体式架构编码方法已无法满足需求,速度和创新已成为成功的关键因素,而微服务架构正是提供这种急需的竞争优势的地方。现在,让我们深入探讨微服务与传统单体式架构方法之间的区别。
整体式架构和微服务方法之间的差异
当要识别这两种模式之间的差异时,重要的是要注意这些架构模式出现在不同的时间,并解决了管理复杂性的不同问题。
采用单体式架构意味着所有代码库都以单一、可部署、统一的单元形式存在、运行和发展。
虽然内部架构清晰,每个模块结构清晰,编写规范,但整个应用程序和代码库只能作为单一可部署单元存在。
例如,让我们看一个购物车应用程序。
整体式镜头
应用程序中会有一些明显的逻辑可以被视为正交的:有用户管理、有支付系统、有显示产品列表、有更新的产品库存、有购物车状态、有管理 UI 等等。
当开发为单体应用时,我们拥有一个单一的系统,它负责独立管理该应用程序的每一个独立、正交的方面。这意味着,在单体应用场景中,如果我们需要部署一个与支付系统相关的变更,则需要将整个应用程序作为一个整体进行部署,因为多个业务关注点属于一个单一、孤立的上下文,无法作为独立的单元存在。
这会导致代码库迅速增长到无法维护的状态,随着时间的推移,添加新功能的成本会越来越高,直到达到一个临界点:新功能的上市时间不再超过开发和维护这些新增功能所需的时间,最终导致应用程序的死亡。
微服务视角
如果我们从新的角度看待上述示例,就会发现有很多机会可以从结构上将应用程序拆分成独立的部分,而这些部分在整体上构成了同一个应用程序,但方式更加灵活。如上所述,以下是我们必须处理的一些服务示例:
- 支付服务;
- 用户管理服务;
- 购物车服务;
- 产品列表服务;
- (...)
由于这些服务中的每一个都与业务逻辑的特定上下文有关,这意味着所有这些服务的组合将形成我们的应用程序。
分解整体结构的好处是,我们可以将应用程序视为一组独立运行的服务:
PaymentsService
,,,,,...UserManagementService
ShoppingCartService
ProductListingService
使用此设置时,如果我们决定更改应用程序处理付款的方式,只需调整代码和内部逻辑PaymentsService
,然后只重新部署该特定服务,所有服务都将在更新的服务上运行。它更安全、更快速、更易于管理,因为我们只需要关注处理需要更改的业务领域的逻辑部分。
这里稍微补充一下,这种微服务思维方式可以看作是一种“面向领域的微服务架构”,因为每个微服务都会形成一个领域界限上下文,这意味着每个服务都会处理业务领域的特定部分。
值得注意的是,微服务由于其快速的反馈周期、更高的模块化和可用性,非常适合在 CI/CD 环境中工作。如今,通过 CI/CD 流水线将 API 以 Docker 服务的形式交付非常容易,并且代码交付速度比使用单体架构快得多。这使得在允许且可行的情况下,使用微服务开发架构几乎成为一种必需。
并非每个应用程序都可以构建为微服务,但是,如果您可以做到,那么这确实是最好的方法。
我们的微服务代码架构的主要组件
现在我们已经了解了使用面向微服务的架构的主要优势,现在是时候开始研究在构建我们自己的理论微服务架构模型实现时将利用的主要组件和抽象了。
在使用 SpringBoot 和 Java 设计微服务架构时,需要准备一些基本的构建块。首先要讨论的是如何将微服务架构实际暴露给客户端使用:
这将使用 REST API 来实现,该 API 将公开专用端点,然后将逻辑委托给我们的服务(因此得名微服务),并返回 JSON 格式的响应。这样,任何客户端,无论是 Web 应用、移动应用等,都可以调用我们公开的 API,并以 JSON 格式检索响应,然后将其显示在网页、移动应用等中。
REST API 是客户端和我们的微服务架构之间的接口表面层,所以现在我们需要详细说明实际的微服务代码将如何构建。
详述微服务代码架构
我们将遵循的架构利用了 SpringBoot 的抽象,这些抽象能够很好地转换为可运行的生产代码。我们先来看一下它的示意图,然后逐步深入每个方面的细节:
现在让我们集中讨论上图中各个组件的细节。
使用 Spring 的@RestController
注解处理请求
当客户端发出请求时,入口点就是所谓的“资源类”。在 SpringBoot 中,资源类定义了客户端请求资源时将要访问的端点 URL。
此类的蓝图如下:
@RestController
public class ShoppingCartResource {
private final PaymentService paymentService;
private final ProductListingService productListingService;
public ShoppingCartResource(PaymentService paymentService,
ProductListingService productListingService) {
this.paymentService = paymentService;
this.productListingService = productListingService;
}
@GetMapping(value = "/list-products", produces =
MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ProductDTO>> getProducts() {
return productListingService.getAllProducts()
.map(product -> ResponseEntity.ok().body(product))
.orElse(ResponseEntity.notFound().build());
@PostMapping(value = "/checkout/{cartId}", produces =
MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> processPayment(@PathVariable String cartId) {
return paymentService.performPayment(cartId);
}
在代码架构层面,甚至在资源类中已经发生了很多事情,所以让我们看看它们是如何连接的。
该类带有注释@RestController
,这意味着它将告诉 Spring 我们将定义端点方法,外部的客户端可以访问该方法并使用我们的 API 检索数据。
@GetMapping
我们可以看到,端点方法带有类似和 的注解@PostMapping
。这意味着,对于特定的 URL,存在一个到特定 Http 动词的 URL 映射。如果我们查看/list-products
上面的代码,我们会发现它被定义为 Get 映射。这意味着对此 URL 发出的请求将采用以下格式:
request:
Http GET https://<the production deployment URL>/list-products
我们还声明响应将采用 JSON 格式。此特定端点不允许使用其他方法。
终点的推理@PostMapping
也是类似的。
资源类中的服务自动装配和注入
我们在上面看到,我们创建的资源类在构造函数中注入了服务。
在最近的 Spring 版本中,这实际上与在这些类中添加注释相同@Autowired
,这意味着由于我们将这些服务作为构造函数参数传递给我们的资源类,因此 Spring 将知道要初始化和实例化哪些服务,以便一切正常。
我们可以看到,端点方法将服务所需的所有内部逻辑委托给服务,因此,它们本质上充当客户端请求和获取为客户端提供服务所需的数据所需的内部逻辑之间的“路由”层。
这些是“微服务”架构名称中的微服务:它们本质上是非常小的专用服务,在处理特定业务领域环境时只做一件事。
我们已经可以看到架构中的第一级“间接”:
资源类不应该知道任何业务逻辑,这应该始终委托给服务,端点方法充当客户端请求和实际数据获取/处理之间的“路由器”。
现在让我们更深入地探究一下我们的架构中服务的构成。
服务结构
正如我们之前所看到的,服务是这种架构模式的核心,并且它们也由额外的抽象组成,因为它们是负责处理更复杂逻辑的组件,这将成为我们 API 的附加值。
服务不仅在规模、复杂性和逻辑方面更加复杂,通常还负责与应用程序的持久层进行交互。这意味着服务处理和管理对数据库的访问以检索数据,并在所需的数据之上实际实现所需的逻辑。
服务还具有易于组合的特性,就像纯粹的函数式构建块一样,我们可以依靠更简单的服务来构建和扩展更复杂、更高级的服务。
例如,假设在产品列表服务中,我们只希望在列表中显示实际有库存的产品。
我们可以把所有这些逻辑都写成 的组成部分ProductListingService
,但这会使这项服务的维护更加复杂,更重要的是,这项服务会承担两项不同的职责:验证特定产品的库存,然后准备产品上架所需的数据。相反,我们可以将库存检查的逻辑提取到单独的服务中,例如调用 ,StockAvailabilityService
这样就可以在不同的情境中重复使用,因为它在不同的情境中都很有用,例如应用促销活动,或者业务增长需要按地理区域检查库存情况等等。让我们来看一个产品上架服务的结构示例:
@Service
public class ProductListingService {
private static final Log LOG = LogFactory.getLog(ProductListingService.class);
private ProductRepository productRepository;
public ProductUpdatingService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<ProductDTO> listProducts() {
return newArrayList(productRepository.findAll());
}
其基本理念是,如果我们拥有一个独立的、可复用的服务,那么我们就可以专注于它在业务能力方面所能提供的功能,并思考它在业务场景中究竟可以在哪些方面被复用,或者被组合成更高级别的服务。这一点非常有价值,也是支持微服务的主要论点之一。
我们已经看到,服务负责处理持久层并管理数据操作,以解决特定的业务需求。然而,服务还有另一个非常重要的角色:
服务负责在应用层和持久层之间来回转换数据格式
我们现在将更深入地研究服务用于与持久层通信的实际抽象,一旦该视图完成,我们就可以缩小范围并查看不同的数据格式以及它们在我们的上下文中为什么有用。
引入存储库模式作为持久层的抽象
正如我们在上面的例子中看到的,我们的服务依赖于我们称之为存储库的接口来抽象持久层,并为我们作为程序员提供对我们的应用程序所使用的数据模型的更高级别的抽象,以及一些开箱即用的有用方法。
存储库是代码级别的接口,它允许我们拥有 SpringBoot 提供的一组方法,这些方法允许我们开箱即用地检索、更新、创建和删除实体,此外,还允许我们编写自己的自定义查询以更复杂的方式检索数据。
存储库类的一个示例如下:
@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {
//this already offers methods inherited from CrudRepository: findAll(), findById(), deleteById() and updateById()
}
除此之外,我们还可以使用自定义查询驱动的自定义方法以更复杂的方式检索数据:
@Repository
public interface ProductRepository extends CrudRepository<Product, Long> {
@Query("select p from Product p where p.name in (:productNames)")
List<Product> retrieveProductsUsingTheirNames(@Param("productNames") List<String> productNames);
}
我们看到,通过方法名称上方的注释指定查询,我们可以使用非常精细调整的查询来检索数据,以满足所有可能的业务需求。
同样需要注意的是,这将成为下一部分的桥梁,这个存储库的返回类型以及的类型参数CrudRepository
是 类型Product
。
这Product
代表了我们数据模型中的实体,因为它存储在数据库中。显然,这是通过 Java 定义的,我们将在下一节中看到。
持久层和应用层之间的数据格式不同
如前所述,数据库和应用程序层之间的数据格式可能不同,即使它们完全相同,将数据库表示抽象为与应用程序代码“紧密耦合”的格式也是一种很好的做法。
通常,我们可以在数据库中存储持久性工作所需的附加字段,例如主键、某种类型与其他类型的详细关系、数据库中特定字段的格式等。
但是,在应用程序层工作时,这些信息通常不是应用程序本身关注的问题,我们甚至可能不想在应用程序代码中处理所有数据库属性。
为了解决这些问题,服务实际上通过使用所谓的“数据传输对象”(简称 DTO)在不同的数据表示之间进行“转换”。
主要思想是,如果我们Product
从数据库中获取一个,那么ProductDTO
在服务级别的某个地方准备好一个,以便我们在下游使用,这是一个好的计划。
数据层面关注点分离的另一个重要方面是,我们完全控制 DTO,因此我们可以通过模拟存储库层并断言 DTO 的维护和构建来对我们的服务进行单元测试,这使得这些服务非常容易测试。
让我们深入了解数据库实体和DTO之间的区别。我们从数据库实体开始:
@Entity
public class Product implements Serializable {
private long id;
private String name;
private long stock;
private Supplier supplier;
public Product() {
}
public Product(String name, long stock, Supplier supplier) {
this.name = name;
this.stock = stock;
this.supplier = supplier;
}
@Id
@GeneratedValue(generator = "product_id_seq", strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "product_id_seq", sequenceName = "product_id_seq", schema = "product", allocationSize = 1)
@Column(name = "id", nullable = false, updatable = false)
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "supplier_id", referencedColumnName = "id", nullable = false)
public Supplier getSupplier() {
return supplier;
}
public void setSupplier(Supplier supplier) {
this.supplier = supplier;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public long getStock() {
return stock;
}
public void setStock(long stock) {
this.stock = stock;
}
我们可以看到,这个带有注解的类@Entity
实际上指向一个数据库类。我们看到了对 id、数据库表名以及许多其他仅与数据库层相关的方面的引用。再次强调,通过使用注解,我们可以利用 SpringBoot 的功能为我们完成大量工作,这正是我们可以使用前面描述的存储库模式的原因。
现在,显然,当在应用层工作时,我们不会直接关注任何与数据库相关的实体。
我们确实需要担心,因为带实体注解的类将成为存储库和数据库之间的接口,但这也只是我们关心的结束。我们只知道,给定一个特定的存储库查询,我们将在代码中收到该实体类的一个实例(或列表、集合等)。
因此,为了将数据库与其余代码隔离,并使我们的生活更轻松,我们可以创建所谓的 DTO 类。
我们可以将 DTO 类视为应用程序“观察”数据库的镜头。应用程序代码只关注 API 客户端所需的确切格式,而捕获该需求的一种方法是使用与其完全匹配的表示形式。这就是 DTO。以下是我们示例域中某个产品的 DTO 可能的样子:
public class ProductDTO {
private String name;
private long stock;
private String supplierName;
public ProductDTO() {}
public ProductDTO(String name, long stock, String supplierName) {
this.name = name;
this.stock = stock;
this.supplierName = supplierName;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSupplierName() {
return supplierName;
}
public void setSupplierName(String supplierName) {
this.supplierName = supplierName;
}
public long getStock() {
return stock;
}
public void setStock(long stock) {
this.stock = stock;
}
}
这看起来已经简单多了!更重要的是,它更贴近我们的业务领域,而且由于所有数据库方面的负担都已消除,使用起来也更加便捷。这对于代码的灵活性和可维护性至关重要。通过完全控制 DTO 的表示,我们现在可以更轻松地管理应用层。我们可以编写和扩展 DTO,可以在数据库级别进行任意复杂的查询,并且我们知道我们的 DTO 能够适应任何需求。这将控制权重新交到我们手中。
如果你还记得,最初我们列出产品的服务返回的是List<ProductDTO>
。这是因为服务关心的是客户端对 API 的需求,而存储库则关心的是数据库。这两个世界之间的接口发生在服务层面。
现在让我们确切地看看如何在单元级别和集成级别对我们的架构进行测试。
测试面向微服务的架构的注意事项
现在我们已经相对深入地研究了我们架构的构建块,我们可以按照类似的方法进行测试。
单元测试服务
让我们首先看看如何在我们的设置中进行单元测试服务。
我们看到,服务通过存储库操作某个数据库实体后会返回一个 DTO,因此,对服务进行单元测试的理想方法是模拟存储库的某个响应,并对服务的返回结果进行断言,以确保其内部逻辑正确实现。因此,计划如下:
-
我们将模拟存储库类,并通过模拟预期响应将它们置于我们的控制之下;
-
使用这些受控数据,我们还可以通过断言其内容来断言我们的服务生成了正确的 DTO;
-
通过验证存储库方法仅被调用一次,断言与存储库类的交互是正确的;
让我们看看实际情况是怎样的:
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@ExtendWith(MockitoExtension.class)
class ProductListingServiceTest {
private ProductListingService productListingService;
@Mock
private ProductRepository productRepository;
@BeforeEach
void setUp() {
this.productListingService = new ProductListingService(productRepository);
Product product = new Product();
product.setName("test");
product.setStock(100L);
product.setSupplier(new Supplier("someSupplier"));
Product product2 = new Product();
product.setName("test2");
product.setStock(101L);
product.setSupplier(new Supplier("someSupplier2"));
}
@Test
void listProduct_lists_correctly() {
doReturn(newArrayList(product,product2).toIterable()).when(productRepository).findAll();
List<productDTO> list = productListingService.listProducts();
verify(productRepository, times(1)).findAll();
assertThat(list.size(),2);
}
我们可以看到,通过模拟存储库,我们通过专门测试服务内部逻辑实现了所需的隔离级别。
注释@ExtendWith(MockitoExtension.class)
用于确保可以通过简单地注释模拟来在测试上下文中配置模拟,@Mock
如上所示。
这样,我们可以准确地测试我们服务所需的方法,并确保其内部逻辑得到很好的实现。
让我们看一下使用 Spring 的集成测试MockMvc
。
使用 MockMvc 编写集成测试
现在,我们有兴趣从端点角度(即从资源层)测试我们的 API。
为了做到这一点,我们可以使用mockMvc
。
MockMvc
是一个 Spring 类,我们可以利用它为我们的端点编写集成测试。本质上,经过一些简单的连接后,我们可以模拟对 API 端点的请求,就像它来自外部客户端一样,并对其返回状态、值和其他信息进行断言,从而以更“端到端”的方式测试 API。
要进行设置,MockMvc
我们只需要在测试类中添加一些注释,如下所示:
@AutoConfigureMockMvc
@SpringBootTest
class ProductsResourceTest {
@Autowired
private MockMvc mockMvc;
(...)
我们添加了两个注释,@SpringBootTest
一个负责连接我们的应用程序上下文、存储库和应用程序从测试上下文中启动所需的其他内容,另一个@AutoConfigureMockMvc
负责配置类以MockMvc
使其真正正确连接。
一旦设置完成,我们就可以看到实例mockMvc
允许我们发送 http 请求,并对响应进行断言,同时保持某些外部依赖关系在我们的控制之下。
此类请求的示例如下:
@AutoConfigureMockMvc
@SpringBootTest
class ShoppingCartResourceTest {
@Autowired
private MockMvc mockMvc;
@Mock
private ProductListingService productListingService;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(
new ProductResource(productListingService))
.build();
}
@Test
void listProducts_with_empty_repository_returns_OK_with_empty_list() throws Exception {
MockMvc result = mockMvc.perform(get("/list-products")
.contentType(MediaType.APPLICATION_JSON_VALUE)
.andExpect(status().isOk());
assertTrue(result.andReturn().getResponse().getContentAsString().equals("{}"));
}
在这里,我们可以从客户端的角度练习整个 API,而且,我们显然可以有更复杂的测试设置,用虚拟数据作为测试数据源的种子,我们可以将 Spring 应用程序连接到该测试上下文数据源,以使用更多真实世界数据模拟请求,但这将是未来文章的主题。
结论
希望在阅读完关于微服务架构的介绍后,您能够更好地了解它何时适合您的项目,如何围绕微服务构建应用程序,并初步了解使用 Spring 对微服务架构进行不同级别的测试。
欢迎提出建议/评论!