Skip to main content

Consul 服务注册与发现

第三章开始,我们开始学习 Spring Cloud,因此标题也改成了 Spring Cloud 速成。

本笔记 Spring Cloud 部分参考了尚硅谷阳老师的教程。Spring Cloud 先讲 Consul,Load Balancer,Spring Interface Client 与 OpenFeign,Resilience4j,MicroMeter,Gateway,Seata,WebFlux,Spring Security,KeyCloak,Rabbit MQ,GraphQL,gRPC 然后是 k8s。尽管上面部分技术严格讲并不属于微服务,但它们属于 Spring 的进阶内容,这里就一起讲了。

微服务架构

微服务架构是一种架构风格,它是一种将单一应用程序开发为一组小型服务的方法,每个服务运行在自己的进程中,服务之间通过 HTTP 或者消息队列通信。每个服务都有自己的数据库,独立部署,独立升级。

例如,像是淘宝这样的购物网站,可能有商品服务,订单服务,用户服务,支付服务等等,每个服务都跑在单独的服务器上,这样可以更好地实现高可用,高并发,高扩展。

当然,不用框架也完全可以做到微服务——我现在就用 python 写一个。

from fastapi import FastAPI
api = FastAPI()
@api.get("/secret")
def get_secret():
return "You found my secret!"
if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8000)
from fastapi import FastAPI
api = FastAPI()
@api.get("/secret")
def get_secret(password: str):
if password != "123456":
return
import requests
return requests.get("http://localhost:8000/secret").text
if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8001)

这样就实现了一个简单的微服务架构,一个服务提供密码验证,另一个服务提供秘密信息。

当然,事情不可能这么简单,当系统开始 scale 之后,会有这样几个问题:

  • 服务注册与发现:不可能全部使用 ip 地址或者域名来访问服务,因为服务可能会动态变化,需要服务注册与发现。需要一个工具来统一处理服务请求并分发给对应的服务。
  • 服务调用:调用其它服务的时候,不可能总是从基础的 http 请求开始,需要一个工具来简化服务调用。
  • 负载均衡:如果我们已经把每个服务部署在多个服务器上,那么如果我们需要更大的 qps,一个自然的想法就是部署更多的同一个服务。但是,这样就会创建多个服务事例,例如服务 A1,服务 A2 都提供同一个服务,为了达到更大的 qps,我们应当将所有的请求均匀分发给 A1 和 A2。这就是负载均衡。
  • 服务熔断:如果一个服务出现问题,例如超时,那么我们应当停止向这个服务发送请求,而不是一直等待,并进行降级,即减少当前支持的功能,以避免雪崩,即服务集群中一个服务的故障,由于没有做好服务熔断,导致依赖这个服务的其它服务也出现故障,最终一层层向下,直到整个系统崩溃。
  • 服务网关:如果我们有多个服务,那么我们应当有一个统一的入口,这个入口可以做一些统一的处理,例如鉴权,限流,日志等等。

Spring Cloud 就是为了解决这些问题而生的。不过,Spring Cloud 是一套又大又复杂的框架,现在也有公司在借助 k8s 加上容器技术实现微服务架构,这样可以不依赖于具体的语言和框架,而是可以自由选择。不过,Spring Cloud 依然是较为流行的微服务框架之一。

今天我们先介绍服务注册与发现。

配置 docker 网络

下文将使用 docker 部署 Consul,但是有个问题,在 macos 和 windows 上,docker本质上是运行在虚拟机上,因此不支持 host 网络模式。Consul 只能发现当前内网的服务,而我们的 java 是运行在宿主机上的。换言之,Consul 只能在 docker 运行的那个虚拟机上找服务,但这个虚拟机被隐藏了起来,不是和宿主机直通的。

当然,如果是虚拟机而不是 docker,那么可以使用 host 网络模式,这样就可以让虚拟机和宿主机在同一个网络上。但是,尽管已经支持了 host 模式,但是在 macos 和 windows 上,docker 本质上还是运行在虚拟机上,因此无法使用 host 模式。准确地说,这个 host 是 mac 和 windows 上那个 docker linux 虚拟机的 host,而非 mac 和 windows 本身。

当然你可以把 java 项目放到 docker 里面,如果在 windows 上,也可以打开 windows container 模式,或者用 linux 系统开发。

一个更好的解决方案是使用 docker-desktop-connector。安装后按照文档配置即可。这样就可以让本机网络和 docker 虚拟机网络联通。又由于 Consul 是绑在虚拟机的 eth0 上的,因此我们可以在本机上连接到虚拟机的 eth0,从而访问 Consul。

Consul 服务注册与发现

启动 Consul

首先,启动 Consul。为了方便开发,我们还一并创建一个数据库。

数据库的 schema 如下:

DROP TABLE IF EXISTS payment;

CREATE TABLE payment (
id SERIAL PRIMARY KEY,
payment_no VARCHAR(50) NOT NULL,
order_no VARCHAR(50) NOT NULL,
user_id INT DEFAULT 1,
amount DECIMAL(8,2) NOT NULL DEFAULT 9.9,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

COMMENT ON TABLE payment IS '支付交易表';
COMMENT ON COLUMN payment.payment_no IS '支付流水号';
COMMENT ON COLUMN payment.order_no IS '订单流水号';
COMMENT ON COLUMN payment.user_id IS '用户账号ID';
COMMENT ON COLUMN payment.amount IS '交易金额';
COMMENT ON COLUMN payment.deleted IS '删除标志,默认0不删除,1删除';
COMMENT ON COLUMN payment.create_time IS '创建时间';
COMMENT ON COLUMN payment.update_time IS '更新时间';

docker compose 文件如下:

services:
postgres:
image: postgres:12.19
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: demo
volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/schema.sql
ports:
- 5432:5432
consul:
image: hashicorp/consul:1.19
ports:
- 8500:8500
volumes:
- ./consul:/consul/data

启动后,访问 http://localhost:8500,可以看到 Consul 的界面。

Consul 界面

注意,Consul 在 docker 容器关闭时是不会保存数据的。

项目配置

利用我们上一章讲的 gradle,配置一个多模块项目。

根项目使用,

plugins {
id 'java'
id "org.springframework.boot" version "3.2.6"
id 'io.spring.dependency-management' version '1.1.5'
}

allprojects {
repositories {
mavenCentral()
}

ext {
set("springCloudVersion", "2023.0.2")
set("springBootVersion", "3.2.6")
set("lombokVersion", "1.18.28")
set("springdocVersion", "2.5.0")
set("postgresqlVersion", "42.7.3")
}

group = 'io.github.fingerbone'
version = '1.0-SNAPSHOT'
}

subprojects {
apply {
plugin 'java'
plugin 'org.springframework.boot'
plugin 'io.spring.dependency-management'
}

java {
sourceCompatibility = JavaLanguageVersion.of(17)
targetCompatibility = JavaLanguageVersion.of(17)
}

dependencyManagement {
imports {
mavenBom "org.springdoc:springdoc-openapi-starter-webmvc-ui:${springdocVersion}"
mavenBom "org.springframework.boot:spring-boot-starter-web:${springBootVersion}"
mavenBom "org.postgresql:postgresql:${postgresqlVersion}"
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}

dependencies {
compileOnly "org.projectlombok:lombok:${lombokVersion}"
annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
}
}

注意,这里的 Spring Cloud 和 Spring Boot 版本必须匹配,否则会出现一些问题。兼容性表见 Spring Cloud 文档

为了方便,引入了 spring doc,加入 spring doc 后,可以访问swagger-ui/index.html来查看接口文档,并进行测试。

此外,org.springframework.cloud:spring-cloud-dependencies是一个包,它包含了 Spring Cloud 的所有依赖,因此只要声明这个的版本,后续使用 Spring Cloud 的任何依赖都会使用这个版本。

第一个子项目只是存放一些公用的类,例如 record,wrapper,enum 等等,不包含任何业务逻辑。目前 builder.gradle 可以留空。

第二个子项目为支付服务,使用 Spring Data JPA 操作数据库,Spring Web 提供 RESTful API。

dependencies {
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.postgresql:postgresql"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation project(":commons")
}

微服务架构中,提供服务的服务称为服务提供者,调用服务的服务称为服务消费者。我们还要写一个下单服务,这个服务会调用支付服务。

它的配置如下,

dependencies {
implementation project(":commons")
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui"
}

在根项目 settings.gradle 下,使用

rootProject.name = 'demo'
include 'payment'
include 'commons'
include 'order'

这里 include 的是子项目的目录名,而不是项目名。后面使用 project 引入子项目时,使用的是项目名而非目录名。不过默认这两个是相同的。

如果要改项目名,需要,

include "foo"
rootProject.name = "bar"
project(":foo").name = "foofoo"

如果你使用 maven,还要在子项目中添加父项目信息。

支付服务业务逻辑

现在,暂时先不考虑微服务,我们先基于上面的数据库,写一点简单的业务逻辑。这里我们使用 Spring Data JPA 来操作数据库。

注意,下面的 record 类,枚举等应当放在 commons 项目中,其它的应当放在 payment 项目中。

package io.github.fingerbone.record;

import java.math.BigDecimal;

public record PaymentRecord(
Long id,
String paymentNo,
String orderNo,
Integer userId,
BigDecimal amount,
Boolean deleted
) {
}
package io.github.fingerbone.entity;

import io.github.fingerbone.record.PaymentRecord;
import jakarta.persistence.*;
import org.springframework.beans.BeanUtils;
import lombok.Data;

import java.math.BigDecimal;
import java.util.Date;

@Entity
@Data
public final class Payment {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "payment_no", nullable = false, length = 50)
private String paymentNo;

@Column(name = "order_no", nullable = false, length = 50)
private String orderNo;

@Column(name = "user_id", nullable = false)
private Integer userId = 1;

@Column(name = "amount", nullable = false, precision = 8, scale = 2)
private BigDecimal amount = BigDecimal.valueOf(9.9);

@Column(name = "deleted", nullable = false)
private Boolean deleted = false;

@Temporal(TemporalType.TIMESTAMP)
@Column(name = "create_time", nullable = false, updatable = false)
private Date createTime;

@Temporal(TemporalType.TIMESTAMP)
@Column(name = "update_time", nullable = false)
private Date updateTime;

@PrePersist
private void onCreate() {
createTime = new Date();
updateTime = new Date();
}

@PreUpdate
private void onUpdate() {
updateTime = new Date();
}

public PaymentRecord toRecord() {
return new PaymentRecord(
id,
paymentNo,
orderNo,
userId,
amount,
deleted
);
}

public static Payment fromRecord(PaymentRecord record) {
Payment payment = new Payment();
BeanUtils.copyProperties(record, payment);
return payment;
}
}
package io.github.fingerbone.repository;

import io.github.fingerbone.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface PaymentRepository extends JpaRepository<Payment, Long> {

}
package io.github.fingerbone.service;

import io.github.fingerbone.entity.Payment;
import io.github.fingerbone.repository.PaymentRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class PaymentService {
private final PaymentRepository paymentRepository;

public PaymentService(
@Autowired PaymentRepository paymentRepository
) {
this.paymentRepository = paymentRepository;
}

public Payment createPayment(Payment payment) {
return paymentRepository.save(payment);
}

public Payment getPayment(Long id) {
return paymentRepository.findById(id).orElse(null);
}

public void deletePayment(Long id) {
Payment payment = paymentRepository.findById(id).orElse(null);
if (payment != null) {
payment.setDeleted(true);
paymentRepository.save(payment);
}
}

public Payment updatePayment(Payment payment) {
return paymentRepository.save(payment);
}

public List<Payment> getAllPayments() {
return paymentRepository.findAll();
}
}
package io.github.fingerbone.wrapper;

public record ResponseWrapper<T>(Integer code, String message, Long time, T data) {

public static <T> ResponseWrapper<T> success(T data) {
return new ResponseWrapper<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMessage(), System.currentTimeMillis(), data);
}

public static <Void> ResponseWrapper<Void> success() {
return new ResponseWrapper<>(ResponseCode.SUCCESS.getCode(), ResponseCode.SUCCESS.getMessage(), System.currentTimeMillis(),null);
}

public static <T> ResponseWrapper<T> error(ResponseCode responseCode, T data) {
return new ResponseWrapper<>(responseCode.getCode(), responseCode.getMessage(), System.currentTimeMillis(), data);
}

public static <Void> ResponseWrapper<Void> error(ResponseCode responseCode) {
return new ResponseWrapper<>(responseCode.getCode(), responseCode.getMessage(), System.currentTimeMillis(), null);
}

public static <Void> ResponseWrapper<Void> error() {
return new ResponseWrapper<>(ResponseCode.INTERNAL_SERVER_ERROR.getCode(), ResponseCode.INTERNAL_SERVER_ERROR.getMessage(), System.currentTimeMillis(), null);
}
}
package io.github.fingerbone.wrapper;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum ResponseCode {
SUCCESS(200, "Success"),
INTERNAL_SERVER_ERROR(500, "Internal Server Error");

private final Integer code;
private final String message;
}
package io.github.fingerbone.wrapper;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ExceptionWrapper {

@ExceptionHandler(RuntimeException.class)
public ResponseWrapper<String> handleException(RuntimeException e) {
return ResponseWrapper.error(ResponseCode.INTERNAL_SERVER_ERROR, e.getMessage());
}

}
package io.github.fingerbone.controller;

import io.github.fingerbone.entity.Payment;
import io.github.fingerbone.record.PaymentRecord;
import io.github.fingerbone.service.PaymentService;
import io.github.fingerbone.wrapper.ResponseWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PutMapping;

import java.util.stream.Collectors;
import java.util.List;

@RestController
@RequestMapping("/payment")
public class PaymentController {
private final PaymentService paymentService;

public PaymentController(
@Autowired PaymentService paymentService
) {
this.paymentService = paymentService;
}

@PostMapping
public ResponseWrapper<PaymentRecord> createPayment(@RequestBody PaymentRecord paymentRecord) {
return ResponseWrapper.success(
paymentService.createPayment(Payment.fromRecord(paymentRecord)).toRecord()
);
}

@GetMapping
public ResponseWrapper<List<PaymentRecord>> getAllPayments() {
return ResponseWrapper.success(
paymentService.getAllPayments().stream().map(Payment::toRecord).collect(Collectors.toList())
);
}

@GetMapping("/{id}")
public ResponseWrapper<PaymentRecord> getPayment(@PathVariable Long id) {
return ResponseWrapper.success(
paymentService.getPayment(id).toRecord()
);
}

@DeleteMapping("/{id}")
public ResponseWrapper<Void> deletePayment(@PathVariable Long id) {
paymentService.deletePayment(id);
return ResponseWrapper.success();
}

@PutMapping("/{id}")
public ResponseWrapper<PaymentRecord> updatePayment(@PathVariable Long id, @RequestBody PaymentRecord paymentRecord) {
Payment payment = Payment.fromRecord(paymentRecord);
payment.setId(id);
return ResponseWrapper.success(
paymentService.updatePayment(payment).toRecord()
);
}

}

这些都不难,都是简单的增删改查。

下单服务业务逻辑

在 python 中,调用另一个 http 服务很简单,只需要使用 requests 即可。但是在 java 中,我们需要使用 RestTemplate 来调用。

package io.github.fingerbone;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
}
package io.github.fingerbone;


import io.github.fingerbone.record.PaymentRecord;
import io.github.fingerbone.wrapper.ResponseWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;

import java.util.List;

@RestController
@RequestMapping("/consumer/payment")
public class ConsumerPaymentController {
private final RestTemplate restTemplate;
private static final String paymentServiceUrl = "http://localhost:8080" + "/payment";

@Autowired
public ConsumerPaymentController(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

@PostMapping
public ResponseWrapper<PaymentRecord> createPayment(@RequestBody PaymentRecord paymentRecord) {
HttpEntity<PaymentRecord> request = new HttpEntity<>(paymentRecord);
ResponseEntity<ResponseWrapper<PaymentRecord>> response = restTemplate.exchange(
paymentServiceUrl,
HttpMethod.POST,
request,
new ParameterizedTypeReference<>() {
}
);
return response.getBody();
}

@GetMapping
public ResponseWrapper<List<PaymentRecord>> getAllPayments() {
ResponseEntity<ResponseWrapper<List<PaymentRecord>>> response = restTemplate.exchange(
paymentServiceUrl,
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {
}
);
return response.getBody();
}

@GetMapping("/{id}")
public ResponseWrapper<PaymentRecord> getPayment(@PathVariable Long id) {
ResponseEntity<ResponseWrapper<PaymentRecord>> response = restTemplate.exchange(
paymentServiceUrl + "/" + id,
HttpMethod.GET,
null,
new ParameterizedTypeReference<>() {
}
);
return response.getBody();
}

@DeleteMapping("/{id}")
public ResponseWrapper<Void> deletePayment(@PathVariable Long id) {
restTemplate.delete(paymentServiceUrl + "/" + id);
return ResponseWrapper.success();
}

@PutMapping("/{id}")
public ResponseWrapper<PaymentRecord> updatePayment(@PathVariable Long id, @RequestBody PaymentRecord paymentRecord) {
restTemplate.put(paymentServiceUrl + "/" + id, paymentRecord);
return ResponseWrapper.success();
}
}

当然,记得要改第二个项目的端口,否则会冲突。

这样,我们便有了微服务的雏形。但是,现在的项目本质上和我们之前 python 的小 demo 没有什么区别,只是多了一些 java 特色的 boilerplate 代码。

服务注册与发现

现在,我们引入服务注册与发现机制。

但是讲实际知识之前,我们先讲 Spring Cloud 的文档怎么看,比如这里我们想使用 Consul。

Spring Cloud页面

先从左侧选择 Spring Cloud Consul,选择 Learn,然后选择 Reference Documentation。之后你会找到你需要的一切知识。

在左侧切换到服务发现,这与我们接下来要介绍的内容完全一致。不过我们的更精简。

不过,有时候 Spring 的文档并不是最新的,如果某些依赖有 GitHub 主页,一般能在 GitHub 上找到最新的文档。

首先,我们需要引入 Spring Cloud Consul 的依赖。

dependencies {
implementation "org.springframework.cloud:spring-cloud-starter-consul-discovery"
}

然后改 application.yml,默认配置如下,

spring:
cloud:
consul:
host: localhost
port: 8500
discovery:
service-name: ${spring.application.name}

可以不写,因为这是默认配置。如果需要改变,可以在这里改。这里配置了 consul 的地址。此外服务名也可以改。

然后,在主启动类,添加@EnableDiscoveryClient启用服务发现。Spring Boot 其实是会自动给你加上的,但是为了明确,我们还是手动写上。

package io.github.fingerbone;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class Main8081 {
public static void main(String[] args) {
SpringApplication.run(Main8081.class, args);
}
}

此外,由于 Consul 需要检查服务的可用性,因此需要健康检查和心跳检查。这里我们使用 actuator 来提供这些信息。

dependencies {
implementation "org.springframework.boot:spring-boot-starter-actuator"
}

然后,我们可以在 Consul 的界面上看到我们的服务。

Consul 服务注册

但是,如果我们使用域名或 IP 地址通信,是不会经过 Consul 的。这样也做不了负载均衡,熔断等等。

Consul 会把服务名注册到 DNS 服务器上,我们可以通过服务名来访问服务。但是,这个 DNS 服务器是 Consul 自己的,我们需要使用 Consul 的 DNS 服务器。

具体而言,我们首先要把 RestTemplate 配置换成,

@Configuration
public class RestTemplateConfig {
@Bean
@LoadBalanced
RestTemplate restTemplate() {
return new RestTemplate();
}
}

这里的@LoadBalanced是使用负载均衡器。使用负载均衡器时,发送的请求会先经过服务注册中心,然后再分发给对应的服务。

@LoadBalanced只是一个接口,由于我们还没有引入负载均衡器,因此此时并没进行负载均衡。这里加上这个的目的是为了使用服务名来访问服务。

然后把网址改成 Consul 上的服务名,我这里是private static final String paymentServiceUrl = "http://payment-service" + "/payment";

这样,我们就可以通过服务名来访问服务了。