java微服务

1. 服务拆分方式

  • 纵向拆分:按照业务模块拆分
  • 横向拆分:抽取公共服务,提高复用性

2. 两种工程结构

2.1 独立 Project

每个服务为一个独立的 Project。

2.2 Maven 聚合

每个服务为一个 Module。

3. 远程调用

Spring 提供了一个 RestTemplate,可以方便地进行 HTTP 请求的发送。

  • 先将RestTemplate注册为一个Bean:

    @Configuration
    public class RemoteCallConfig {

    复制代码
      @Bean
      public RestTemplate restTemplate() {
          return new RestTemplate();
      }

    }

  • RestTemplate的exchange方法:

    public ResponseEntity exchange(
    String url, // 请求地址
    HttpMethod method, // 请求方法 (GET/POST/PUT/DELETE 等)
    HttpEntity<?> requestEntity,// 请求实体(含 Headers + Body)
    Class responseType, // 响应要转换的目标类型
    Object... uriVariables // URL 中的路径参数(可变参数)
    )

发送请求时,注入restTemplate然后调用exchange方法设置好参数,然后就可以得到response

4. 服务治理

但是用 RestTemplate 时,一个服务可能有多个实例,或者一个服务挂了需要切换到其他实例,这个时候如果用 RestTemplate 远程调用,就得改 RestTemplate 调用的 IP 和端口,会很麻烦,这就涉及到服务治理问题。

这时候就需要一个注册中心,服务将信息注册到注册中心,然后调用者直接在注册中心查询自己需要的服务,然后调用。

4.1 Nacos 注册中心

Nacos 本身也有一些数据需要被存储,所以得配置数据库。nacos.env 是 Nacos 的配置文件,在里面进行数据源的部署。

4.2 服务注册

  1. 引入 Nacos Discovery 依赖
xml 复制代码
spring-cloud-starter-alibaba-nacos-discovery
  1. 配置 Nacos 地址
yaml 复制代码
spring:
  application:
    name: 服务名称
  cloud:
    nacos:
      server-addr: nacos地址

4.3 服务发现

另外,服务发现需要用到一个工具 DiscoveryClient,Spring Cloud 已经帮我们自动装配,我们可以直接注入使用。

java 复制代码
private final DiscoveryClient discoveryClient;

private void handleCartItems(List<CartVO> vos) {
    // 1. 根据服务名称,拉取服务的实例列表
    List<ServiceInstance> instances = discoveryClient.getInstances("item-service");
    // 2. 负载均衡,挑选一个实例
    ServiceInstance instance = instances.get(RandomUtil.randomInt(instances.size()));
    // 3. 获取实例的IP和端口
    URI uri = instance.getUri();
    // ... 略
}

然后取出 URI 就可以调用了(URI 包含 IP 和端口)。

5. OpenFeign

5.1 快速入门

OpenFeign 是一个声明式的 HTTP 客户端,基于常见注解实现 HTTP 请求。

  1. 引入依赖,包括 OpenFeign 和负载均衡组件
xml 复制代码
spring-cloud-starter-openfeign
spring-cloud-starter-loadbalancer
  1. 通过 @EnableFeignClients 注解启动 OpenFeign 功能
  2. 编写 FeignClient
java 复制代码
@FeignClient("item-service")
public interface ItemClient {

    @GetMapping("/items")
    List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}

这里只需要声明接口,无需实现方法。接口中的几个关键信息:

  • @FeignClient("item-service"):声明服务名称
  • @GetMapping:声明请求方式
  • @GetMapping("/items"):声明请求路径
  • @RequestParam("ids") Collection<Long> ids:声明请求参数
  • List<ItemDTO>:返回值类型

有了上述信息,OpenFeign 就可以利用动态代理帮我们实现这个方法,并且向 http://item-service/items 发送一个 GET 请求,携带 ids 为请求参数,并自动将返回值处理为 List<ItemDTO>。我们只需要直接调用这个方法,即可实现远程调用了。

5.2 使用 FeignClient

注入接口,然后调用接口方法即可。

java 复制代码
private ItemClient itemClient;
List<ItemDTO> items = itemClient.queryItemByIds(List.of(1, 2, 3));

5.3 连接池

Feign 底层发起 HTTP 请求,依赖于其它的框架。其底层支持的 HTTP 客户端实现包括:

  • HttpURLConnection:默认实现,不支持连接池
  • Apache HttpClient:支持连接池
  • OKHttp:支持连接池

因此我们通常会使用带有连接池的客户端来代替默认的 HttpURLConnection。比如,我们使用 OKHttp。

  1. 引入依赖
xml 复制代码
feign-okhttp
  1. 开启连接池功能
yaml 复制代码
feign:
  okhttp:
    enabled: true

重启服务,连接池就生效了。

5.4 优化方案

当一个服务的实体类和业务一直被其他服务重复调用时,其他服务就要重复地写接口,非常麻烦。

两种方案:

  1. 将这个服务进行拆分,将它的这些实体类、Client 接口、业务拆分为模块放到该服务的模块下,到时候谁需要就在 pom 中引入即可
  2. 将这些通用的实体类、Client 统一写到一个模块,到时候谁需要谁就引入

怎么选择呢?

前面说到微服务架构有两种:

  • 一种是每个服务是一个独立的 Project,这种就可以使用方案一
  • 第二种就是 Maven 聚合结构,使用方案二

注意引入模块后,一些不在启动类包下的 Client 无法被扫描到,也就无法加入 IOC 容器。解决办法很简单,在启动类上添加声明即可,两种方式:

  • 方式1:声明扫描包
java 复制代码
@EnableFeignClients("要扫描的client包路径")
  • 方式2:声明要用的 FeignClient
java 复制代码
@EnableFeignClients(clients = {client类})

5.5 日志

OpenFeign 只会在 FeignClient 所在包的日志级别为 DEBUG 时,才会输出日志。而且其日志级别有 4 级:

  • NONE:不记录任何日志信息,这是默认值
  • BASIC:仅记录请求的方法,URL 以及响应状态码和执行时间
  • HEADERS:在 BASIC 的基础上,额外记录了请求和响应的头信息
  • FULL:记录所有请求和响应的明细,包括头信息、请求体、元数据

Feign 默认的日志级别就是 NONE,所以默认我们看不到请求日志。

定义日志级别

在模块下新建一个配置类,定义 Feign 的日志级别:

java 复制代码
import feign.Logger;
import org.springframework.context.annotation.Bean;

public class DefaultFeignConfig {
    @Bean
    public Logger.Level feignLogLevel() {
        return Logger.Level.FULL;
    }
}

配置

接下来,要让日志级别生效,还需要配置这个类。有两种方式:

  • 局部生效:在某个 FeignClient 中配置,只对当前 FeignClient 生效
java 复制代码
@FeignClient(value = "", configuration = DefaultFeignConfig.class)
  • 全局生效 :在 @EnableFeignClients 中配置,针对所有 FeignClient 生效
java 复制代码
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)

6. 网关

网络的关口,负责请求的路由、转发、身份校验。

6.1 Spring Cloud Gateway

快速入门

  1. 引入依赖
xml 复制代码
spring-cloud-starter-gateway
  1. 编写启动类
  2. 配置路由规则
yaml 复制代码
spring:
  application:
    name: gateway
  cloud:
    nacos:
      server-addr:
    gateway:
      routes:
        - id: item # 路由规则id,自定义,唯一
          uri: lb://服务名 # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
          predicates: # 路由断言,判断当前请求是否符合当前规则,符合则路由到目标服务
            - Path= # 这里是以请求路径作为判断规则

6.2 路由属性

  • id:路由唯一标识
  • uri:路由目标地址
  • predicates:路由断言,判断请求是否符合当前路由
  • filters:路由过滤器,对请求或响应做特殊处理

6.3 断言类型

Spring Cloud Gateway 中支持的断言类型有很多:

名称 说明 示例
After 是某个时间点后的请求 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 是某个时间点之前的请求 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 是某两个时间点之间的请求 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 请求必须包含某些 Cookie - Cookie=chocolate, ch.p
Header 请求必须包含某些 Header - Header=X-Request-Id, \d+
Host 请求必须是访问某个 Host(域名) - Host=**.somehost.org,**.anotherhost.org
Method 请求方式必须是指定方式 - Method=GET,POST
Path 请求路径必须符合指定规则 - Path=/red/{segment},/blue/**
Query 请求参数必须包含指定参数 - Query=name, Jack 或者 - Query=name
RemoteAddr 请求者的 IP 必须是指定范围 - RemoteAddr=192.168.1.1/24
Weight 权重处理 ---

6.4 网关工作流程

  1. 客户端请求进入网关后由 HandlerMapping 对请求做判断,找到与当前请求匹配的路由规则(Route),然后将请求交给 WebHandler 去处理。
  2. WebHandler 则会加载当前路由下需要执行的过滤器链(Filter chain),然后按照顺序逐一执行过滤器(后面称为 Filter)。
  3. Filter 分为两部分,是因为 Filter 内部的逻辑分为 pre 和 post 两部分,分别会在请求路由到微服务之前和之后被执行。
  4. 只有所有 Filter 的 pre 逻辑都依次顺序执行通过后,请求才会被路由到微服务。
  5. 微服务返回结果后,再倒序执行 Filter 的 post 逻辑。
  6. 最终把响应结果返回。

最终请求转发是有一个名为 NettyRoutingFilter 的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter 之前,这就符合我们的需求了!

6.5 自定义过滤器

网关过滤器链中的过滤器有两种:

  • GatewayFilter:路由过滤器,作用范围比较灵活,可以是任意指定的路由 Route
  • GlobalFilter:全局过滤器,作用范围是所有路由,不可配置
java 复制代码
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 编写过滤器逻辑
        System.out.println("未登录,无法访问");
        // 放行
        // return chain.filter(exchange);

        // 拦截
        ServerHttpResponse response = exchange.getResponse();
        response.setRawStatusCode(401);
        return response.setComplete();
    }

    @Override
    public int getOrder() {
        // 过滤器执行顺序,值越小,优先级越高
        return 0;
    }
}

7. 微服务获取用户

现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?

由于网关发送请求到微服务依然采用的是 HTTP 请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用 Spring MVC 的拦截器来实现登录用户信息获取,并存入 ThreadLocal,方便后续使用。

因此,接下来我们要做两件事:

  • 改造网关过滤器,在获取用户信息后保存到请求头,转发到下游微服务
  • 编写微服务拦截器,拦截请求获取用户信息,保存到 ThreadLocal 后放行

使用 exchangemutate 方法可以改变下游的请求信息,然后就可以在这加入请求头,将用户信息加入请求头。

java 复制代码
String userInfo = userId.toString();
ServerWebExchange ex = exchange.mutate()
        .request(builder -> builder.header("user-info", userInfo))
        .build();

// 放行
return chain.filter(ex);

编写拦截器后注意两件事:

  1. 其他微服务扫描不到拦截器的配置类,为了让配置生效要将配置类放入 spring.factories
  2. Spring Cloud Gateway 不引入 Spring MVC,所以 Gateway 服务不能扫描该配置,解决办法很简单:Spring MVC 有通用的类文件 DispatcherServlet,而 Gateway 没有,就可以在配置类上加入条件注解 @ConditionalOnClass(DispatcherServlet.class),这样 Gateway 就不会加载该配置了

8. OpenFeign 传递用户

前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。

但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。

由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。

微服务之间调用是基于 OpenFeign 来实现的,并不是我们自己发送的请求。我们如何才能让每一个由 OpenFeign 发起的请求自动携带登录用户信息呢?

这里要借助 Feign 中提供的一个拦截器接口:feign.RequestInterceptor

我们只需要实现这个接口,然后实现 apply 方法,利用 RequestTemplate 类来添加请求头,将用户信息保存到请求头中。这样一来,每次 OpenFeign 发起请求的时候都会调用该方法,传递用户信息。

9. 配置管理

微服务重复配置过多,维护成本高,引入配置管理服务。

配置更改后服务要重启。

配置管理能实现快速更改配置,同时实现配置热更新。

9.1 配置共享

我们可以把微服务共享的配置抽取到 Nacos 中统一管理,这样就不需要每个微服务都重复配置了。分为两步:

  • 在 Nacos 中添加共享配置。因为正常流程下,Nacos 共享配置在 application.yaml 之前加载,这就会导致 application 配置里 Nacos 地址无法被读取,也就无法加载 Nacos 中的配置
  • 微服务拉取配置(bootstrap.yaml)。bootstrap.yaml 的加载比 application.yaml 要早

步骤:

  1. 引入依赖
xml 复制代码
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
  1. 新建 bootstrap.yaml 文件
yaml 复制代码
spring:
  application:
    name:  # 服务名称
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr:  # nacos地址
      config:
        file-extension: yaml # 文件后缀名
        shared-configs: # 共享配置
          - dataId: shared-jdbc.yaml # 共享mybatis配置
          - dataId: shared-log.yaml # 共享日志配置
          - dataId: shared-swagger.yaml # 共享日志配置

9.2 配置热更新

修改配置后微服务无需重启就能使配置生效。

  1. Nacos 中要有一个与微服务名有关的配置。dataId 格式:[服务名]-[spring.active.profile].[后缀名]
  2. 微服务读取配置,如让一个 Properties 类加注解 @ConfigurationProperties(prefix = "")

9.3 动态路由

网关的路由配置全部是在项目启动时由 org.springframework.cloud.gateway.route.CompositeRouteDefinitionLocator 在项目启动的时候加载,并且一经加载就会缓存到内存中的路由表内(一个 Map),不会改变。也不会监听路由变更,所以,我们无法利用配置热更新来实现路由更新。

步骤:

  1. 引入依赖
xml 复制代码
spring-cloud-starter-alibaba-nacos-config
spring-cloud-starter-bootstrap
  1. 编写 bootstrap.yaml 文件,修改 Gateway 的 resources 目录下的 application.yml,把之前的路由移除

  2. 定义配置监听器:

java 复制代码
@Component
@RequiredArgsConstructor
public class DynamicRouteLoader {
    private final RouteDefinitionWriter writer;
    private final NacosConfigManager nacosConfigManager;
    // 路由配置文件的id和分组
    private final String dataId = "gateway-routes.json";
    private final String group = "DEFAULT_GROUP";
    // 保存更新过的路由id
    private final Set<String> routeIds = new HashSet<>();

    @PostConstruct
    public void initRouteConfigListener() throws NacosException {
        // 1.注册监听器并首次拉取配置
        String configInfo = nacosConfigManager.getConfigService()
                .getConfigAndSignListener(dataId, group, 5000, new Listener() {
                    @Override
                    public Executor getExecutor() {
                        return null;
                    }

                    @Override
                    public void receiveConfigInfo(String configInfo) {
                        updateConfigInfo(configInfo);
                    }
                });
        // 2.首次启动时,更新一次配置
        updateConfigInfo(configInfo);
    }

    private void updateConfigInfo(String configInfo) {
        log.debug("监听到路由配置变更,{}", configInfo);
        // 1.反序列化
        List<RouteDefinition> routeDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class);
        // 2.更新前先清空旧路由
        // 2.1.清除旧路由
        for (String routeId : routeIds) {
            writer.delete(Mono.just(routeId)).subscribe();
        }
        routeIds.clear();
        // 2.2.判断是否有新的路由要更新
        if (CollUtils.isEmpty(routeDefinitions)) {
            // 无新路由配置,直接结束
            return;
        }
        // 3.更新路由
        routeDefinitions.forEach(routeDefinition -> {
            // 3.1.更新路由
            writer.save(Mono.just(routeDefinition)).subscribe();
            // 3.2.记录路由id,方便将来删除
            routeIds.add(routeDefinition.getId());
        });
    }
}

接下来,我们直接在 Nacos 控制台添加路由配置,路由文件名为 gateway-routes.json,类型为 JSON:

json 复制代码
{
    "id": "",
    "predicates": [{
        "name": "",
        "args": {}
    }],
    "filters": [],
    "uri": ""
}

和之前 YAML 文件差不多,只是格式变化了,因为 JSON 格式更容易读取为 String,也就是上面要得到的 configInfo

相关推荐
吃饱了得干活18 小时前
从0到1实现消息已读未读:从基础设计到高并发架构
java·后端·架构
weixin_BYSJ198718 小时前
django在线图书销售平台---附源码16192
java·javascript·spring boot·python·django·flask·php
糖果店的幽灵19 小时前
【langgraph 从入门到精通graphApi 篇】Command 与动态流程控制
android·java·数据库·人工智能·langgraph
只会CRUD的码仔19 小时前
【踩坑记录】Thymeleaf 下拉框设置 disabled 变灰色,但依旧可以点击选择
java
减瓦19 小时前
Java 8 编译器扩展点
java
_waylau19 小时前
Spring Framework HTTP服务客户端详解
java·后端·网络协议·spring·http·spring cloud
蓝银草同学19 小时前
Stream 数据统计实战:求和、平均值、分组汇总(AI 辅助学习 Java 8)
java·前端·后端
大模型丫丫19 小时前
Skill-Agent 如何实践:从概念到落地的完整指南
java·大数据·人工智能
NWU_LK19 小时前
【WebFlux】第四篇 —— 响应式异常处理与测试
java