【微服务】限流、熔断和降级(持续更新中~)

1、限流

1.1 什么是限流

限流(Rate Limiting)是一种常用的技术手段,用于控制系统对资源的访问速率,确保系统的稳定性和可靠性。在分布式系统、Web服务、API接口等场景中,限流尤为重要。通过限制请求的频率或数量,可以有效防止因突发流量导致系统过载、崩溃或资源耗尽等问题。限流不仅保护了服务提供者的利益,也提升了用户体验。

1.2 限流的常见场景

  1. API接口保护:防止恶意用户或爬虫程序对API进行高频访问,消耗大量服务器资源。
  2. 分布式系统:在微服务架构中,限制各服务间的调用频率,避免服务间的级联失败。
  3. 数据库访问:限制对数据库的查询或写入操作,防止数据库因压力过大而响应缓慢或崩溃。
  4. 网络带宽控制:在网络设备或服务器上实施带宽限制,确保关键业务的网络传输质量。

1.3 限流策略

  1. 基于IP的限流:对每个访问者的IP地址进行限流,防止单个IP的恶意访问。
  2. 基于用户的限流:对注册用户进行限流,根据用户身份或角色分配不同的请求额度。
  3. 基于接口的限流:对不同的API接口分别设置限流规则,根据接口的重要性和访问频率进行调整。
  4. **基于时间段的限流:**在特定的时间段内对请求进行限流,如高峰期限制请求频率,低峰期放宽限制。

1.4 限流算法

  1. 固定窗口算法:将时间划分为固定的窗口,每个窗口内统计请求数量,超过阈值则拒绝服务。该算法实现简单,但可能存在临界问题,即窗口切换时突然允许大量请求通过。

  2. 滑动窗口算法:相对于固定窗口,滑动窗口算法允许窗口在时间轴上滑动,通过两个固定窗口(当前窗口和上一个窗口)的叠加来统计请求数量,解决了临界问题,但实现复杂度稍高。

  3. 漏桶算法(Leaky Bucket):将请求视为水滴,以固定速率从桶中漏出。如果水滴到达速率超过漏出速率,则桶满后多余的水滴将被丢弃。该算法平滑了请求的突发流量,但可能导致低优先级请求长时间等待。

  4. 令牌桶算法(Token Bucket):与漏桶算法类似,但令牌桶以固定速率向桶中添加令牌(代表服务容量)。当请求到达时,如果桶中有足够的令牌,则请求被处理并消耗一个令牌;否则,请求被限流。该算法既能应对突发流量,又能保证请求的公平性。

2、熔断

2.1 什么是熔断

熔断(Circuit Breaker)模式是一种用于处理分布式系统中因服务调用失败而可能导致系统雪崩效应的保护机制。它借用了电路中的"熔断器"概念,当电流过大时,熔断器会自动切断电路,以保护整个电路系统不被烧毁。在分布式系统中,熔断器用于监控服务调用的健康状况,并在检测到异常(如服务调用失败率过高、响应时间过长等)时,自动切断对该服务的调用,从而防止故障在系统中蔓延,保障系统的整体稳定性和可用性。

2.2 熔断的目的

  1. 防止系统雪崩:当某个服务出现故障时,如果没有有效的隔离措施,故障可能会迅速扩散到整个系统,导致系统雪崩。熔断机制能够在服务故障时及时切断调用链,防止故障扩散。
  2. 提升系统弹性:通过熔断机制,系统能够在面对服务故障时保持一定的弹性,即能够在故障恢复后快速恢复正常服务,减少因故障导致的服务中断时间。
  3. 优化用户体验:在熔断期间,系统可以返回预设的降级响应(如默认值、缓存数据或错误信息),减少用户等待时间和错误率,提升用户体验。

2.3 熔断的常见场景

  1. 服务调用超时:当下游服务的响应时间超过预设的阈值时,如果继续调用可能会导致上游服务资源耗尽,影响整体系统性能。此时,熔断机制可以切断对下游服务的调用,避免超时问题进一步恶化。

  2. 服务调用失败率过高:如果某个服务的调用失败率达到一定阈值,说明该服务可能存在问题,继续调用可能会浪费系统资源并影响用户体验。熔断机制可以在此时介入,切断对该服务的调用,并返回降级响应。

  3. 系统资源紧张:在高峰期或系统资源不足时,为了保障核心功能的稳定运行,可以通过熔断机制对部分非核心服务进行降级处理,释放系统资源给关键服务使用。

  4. 硬件故障或网络问题:当下游服务所在的服务器或网络出现故障时,可能导致服务不可用。熔断机制可以及时发现并切断对故障服务的调用,防止故障扩散。

  5. 缓存击穿或穿透:在缓存服务中,如果大量请求同时访问未命中缓存的数据(缓存击穿)或不存在的数据(缓存穿透),可能会直接对数据库造成压力。此时,可以通过熔断机制对这类请求进行限流或降级处理。

2.4 熔断策略

  1. 基于失败率的熔断策略:当服务调用的失败率达到预设的阈值时,触发熔断。这种策略适用于对服务稳定性要求较高的场景,可以确保在服务出现问题时及时切断调用链。

  2. 基于响应时间的熔断策略:当服务调用的响应时间超过预设的阈值时,触发熔断。这种策略适用于对服务响应时间有严格要求的场景,如在线交易系统、实时数据处理等。

  3. 基于异常比例的熔断策略:当单位统计时长内异常请求(如抛出异常、返回错误码等)的比例超过设定的阈值时,触发熔断。这种策略适用于服务出现不稳定情况、异常情况较多的场景。

  4. 基于异常数的熔断策略:当单位统计时长内异常请求的数量超过设定的阈值时,触发熔断。这种策略适用于对异常请求数量敏感的场景,如高并发的Web服务中突然出现大量异常请求的情况。

  5. 滑动窗口熔断策略:通过滑动窗口来记录一段时间内的请求情况,并根据窗口内的请求失败率或响应时间等指标来判断是否触发熔断。这种策略可以更加灵活地应对系统负载的变化。

  6. 半开状态策略:在熔断器处于开启状态一段时间后,进入半开状态,允许部分请求通过以测试服务是否已恢复正常。如果测试请求成功,则熔断器关闭;如果测试请求失败,则熔断器重新进入开启状态并延长休眠时间。这种策略有助于在保障系统稳定性的同时,尽快恢复对服务的调用。

2.5 熔断工作流程

  1. 闭合状态:熔断器处于正常工作状态,允许服务调用通过。此时,系统会监控服务调用的健康状况,如失败率、响应时间等。
  2. 开启状态:当服务调用满足熔断条件(如失败率达到阈值)时,熔断器进入开启状态,自动切断对该服务的调用。此时,所有对该服务的调用都将直接返回降级响应,不再实际执行。
  3. 半开启状态:熔断器在开启一段时间后(称为"休眠时间"),会进入半开启状态。在此状态下,系统会允许少量的服务调用通过,以测试服务是否已恢复正常。如果测试调用成功,熔断器将重新进入闭合状态;如果测试调用失败,熔断器将再次进入开启状态,并延长休眠时间。

3、降级

3.1 什么是降级

降级(Degrade)是分布式系统和高可用架构设计中的一个重要概念,旨在系统资源紧张或发生故障时,通过牺牲部分非核心业务功能或降低服务性能的方式,来保障系统整体的可用性和稳定性。降级是一种自我保护机制,它允许系统在面对压力时,主动减少负载,从而避免系统全面崩溃或资源耗尽。

3.2 降级的常见场景

  1. 服务依赖故障:当系统依赖的外部服务(如数据库、缓存、第三方API等)出现故障或响应缓慢时,可以通过降级策略来减少对这些服务的依赖,使用本地缓存、备用数据源或模拟数据等方式来保障核心功能的运行。

  2. 系统资源不足:在高峰期或系统资源紧张时,通过降级非核心功能来释放资源,确保核心功能的稳定运行。例如,在电商大促期间,可以降级非交易相关的功能(如商品评论、推荐系统等),以保障交易系统的流畅运行。

  3. 服务版本不兼容:在微服务架构中,不同服务之间可能存在版本不兼容的问题。当某个服务升级后,与其他服务存在兼容性问题时,可以通过降级策略来回退到旧版本,以保障系统的整体稳定性。

  4. 安全或合规性要求:在某些情况下,为了满足安全或合规性要求,可能需要降级部分功能或数据。例如,在发现数据泄露风险时,可以降级涉及敏感数据的服务,以防止数据进一步泄露。

  5. 功能优先级调整:在业务需求发生变化时,可能需要重新评估各功能的优先级。通过降级非优先功能,可以确保有限的资源被用于支持最重要的业务场景。

  6. 性能测试和故障演练:在进行性能测试或故障演练时,为了模拟真实环境下的系统压力,可以主动对部分服务进行降级处理,以观察系统的表现和恢复能力。

3.3 降级策略

  1. 开关降级:通过配置开关来启用或禁用某些功能或服务,实现快速降级。
  2. **限流降级:**当系统流量超过预设阈值时,对部分请求进行限流或拒绝服务,以减少系统负载。
  3. 熔断降级:当服务调用失败率达到一定阈值时,自动触发熔断机制,将后续请求直接返回降级响应,避免对下游服务造成更大压力。
  4. 资源隔离降级:通过隔离不同服务的资源(如线程池、数据库连接等),防止单个服务的故障影响整个系统。当某个服务资源耗尽时,对其进行降级处理,以保障其他服务的正常运行。
  5. 超时降级:当服务调用超过预设的超时时间时,自动返回降级响应,防止长时间等待导致的资源浪费和用户体验下降。

3.4 降级和熔断的区别

降级和熔断都是系统保护机制,但侧重点不同。熔断侧重于在故障发生时快速切断服务调用链,防止故障扩散;而降级则是在系统资源紧张或故障发生时,通过牺牲部分非核心功能来保障系统整体可用性和稳定性。两者往往结合使用,形成更完善的系统保护体系。

4、 Sentinel 实现方案

Sentinel 提供了 @SentinelResource 注解用于定义资源,并提供了 AspectJ 的扩展用于自动定义资源、处理 BlockException 等。使用 Sentinel Annotation AspectJ Extension 的时候需要引入以下依赖:

XML 复制代码
		<dependency>
			<groupId>com.alibaba.csp</groupId>
			<artifactId>sentinel-core</artifactId>
			<version>1.8.8</version>
		</dependency>
		<dependency>
			<groupId>com.alibaba.csp</groupId>
			<artifactId>sentinel-annotation-aspectj</artifactId>
			<version>1.8.8</version>
		</dependency>

4.1 官方示例

java 复制代码
public class TestService {

    // 对应的 `handleException` 函数需要位于 `ExceptionUtil` 类中,并且必须为 static 函数.
    @SentinelResource(value = "test", blockHandler = "handleException", blockHandlerClass = {ExceptionUtil.class})
    public void test() {
        System.out.println("Test");
    }

    // 原函数
    @SentinelResource(value = "hello", blockHandler = "exceptionHandler", fallback = "helloFallback")
    public String hello(long s) {
        return String.format("Hello at %d", s);
    }
    
    // Fallback 函数,函数签名与原函数一致或加一个 Throwable 类型的参数.
    public String helloFallback(long s) {
        return String.format("Halooooo %d", s);
    }

    // Block 异常处理函数,参数最后多一个 BlockException,其余与原函数一致.
    public String exceptionHandler(long s, BlockException ex) {
        // Do some log here.
        ex.printStackTrace();
        return "Oops, error occurred at " + s;
    }
}

需要通过配置的方式将 SentinelResourceAspect 注册为一个 Spring Bean:

java 复制代码
@Configuration
public class SentinelAspectConfiguration {

    @Bean
    public SentinelResourceAspect sentinelResourceAspect() {
        return new SentinelResourceAspect();
    }
}

官方文档:https://sentinelguard.io/zh-cn/docs/introduction.html

官方案例:Sentinel/sentinel-demo at master · alibaba/Sentinel · GitHub

4.2 流量控制(自测)

一条限流规则主要由下面几个因素组成,我们可以组合这些元素来实现不同的限流效果:

  • resource:资源名,即限流规则的作用对象
  • count: 限流阈值
  • grade: 限流阈值类型,QPS 或线程数
  • strategy: 根据调用关系选择策略

基于QPS/并发数的流量控制

FlowRule.grade 字段控制了统计QPS还是统计并发数。

1、定义流量限制规则

java 复制代码
@Component
public class SentinelRuleInitializer {

    @PostConstruct
    private static void initFlowQpsRule() {
        List<FlowRule> rules = new ArrayList<FlowRule>();
        FlowRule rule1 = new FlowRule();
        //定义资源名称
        rule1.setResource("flowControl");
        //限流阈值
        rule1.setCount(1);
        //限流阈值类型 0-并发数 1-QPS
        rule1.setGrade(RuleConstant.FLOW_GRADE_QPS);
        //超出阈值后处理手段 0-直接拒绝 1-冷启动 2-匀速器 3-
        rule1.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT);
        //应用来源限制
        rule1.setLimitApp(RuleConstant.LIMIT_APP_DEFAULT);
        rules.add(rule1);
        FlowRuleManager.loadRules(rules);
    }
}

2、注入bean

java 复制代码
@Configuration
public class SentinelAspectConfiguration {

    @Bean
    public SentinelResourceAspect sentinelResourceAspect() {
        return new SentinelResourceAspect();
    }
}

3、实现接口

注意!!!一开始没注意到踩了坑~

// Fallback 函数,函数签名与原函数一致或加一个 Throwable 类型的参数.

// Block 异常处理函数,参数最后多一个 BlockException,其余与原函数一致.

java 复制代码
@Slf4j
@RestController
@RequestMapping("/sentinel")
public class SentinelController {
    private Integer count = 0;

    @SneakyThrows
    @GetMapping("/flow/control")
    @SentinelResource(value = "flowControl", blockHandler = "handleBlock", fallback = "exceptHandler")
    public String flowControl() {
        return "hello flow control!" + count++;
    }

    // 发生异常时 进入该方法
    public String exceptHandler(BlockException ex) {
        log.info("发生异常{}", count);
        return "发生异常";
    }

    // sentinel发生异常时 BlockException 进入该方法
    public String handleBlock(BlockException ex) {
        log.info("发生限流、降级和熔断{}", count);
        return "发生降级、降级和熔断" + ex;
    }
}

4、测试

限流规则是 QPS为1,即1秒最多请求1次,请求正常结果如下:

当快速点击后,请求结果如下:

4.2 熔断降级(自测)

  1. 自定义熔断规则
java 复制代码
    @PostConstruct
    private static void initDegradeRule() {
        List<DegradeRule> rules = new ArrayList<>();
        DegradeRule rule = new DegradeRule("flowControl")
                //熔断策略,支持 0-慢调用比例/ 1-异常比例/ 2-异常数策略
                .setGrade(CircuitBreakerStrategy.SLOW_REQUEST_RATIO.getType())
                //慢调用比例模式下为慢调用临界 响应时间(超出该值计为慢调用);异常比例/异常数模式下为对应的阈值
                .setCount(5)
                // 熔断时长,单位为 s
                .setTimeWindow(10)
                //慢调用比例阈值,仅慢调用比例模式有效(1.8.0 引入)
                .setSlowRatioThreshold(0.6)
                // 熔断触发的最小请求数,请求数小于该值时即使异常比率超出阈值也不会熔断(1.7.0 引入)
                .setMinRequestAmount(1)
                //统计时长(单位为 ms),如 60*1000 代表分钟级(1.8.0 引入)
                .setStatIntervalMs(20000);
        rules.add(rule);
        DegradeRuleManager.loadRules(rules);
    }
  1. controller 增加10s时间
java 复制代码
 @SneakyThrows
    @GetMapping("/flow/control")
    @SentinelResource(value = "flowControl", blockHandler = "handleBlock", fallback = "exceptHandler")
    public String flowControl() {
        Thread.sleep(10);
        return "hello flow control!" + count++;
    }
  1. 访问页面,由于每次请求都超出5s,所以5s后第二次请求一定是发生熔断,熔断时间10s,即第一次请求15s后才能再次请求(前5秒是可以发起请求的)

可以看到报错的类已经从FlowException 变成 DegradeException

4.3 Sentinel 控制台

官方 jar包下载:https://github.com/alibaba/Sentinel/releases

下载后使用如下命令启动,注意更改最后的文件名sentinel-dashboard.jar

bash 复制代码
java -Dserver.port=8080 -Dcsp.sentinel.dashboard.server=localhost:8080 -Dproject.name=sentinel-dashboard -jar sentinel-dashboard.jar

应用绑定sentinel dashboard

bash 复制代码
spring.cloud.sentinel.transport.dashboard = localhost:8081

打开本地页面(我这里更改端口为8081,默认8080):http://localhost:8081/#/login

默认用户名和密码都是 sentinel

进入后即可配置限流、熔断规则等。

4.5 源码解析(未完成)

比较重要的类有

  1. Entry 接口:这是 Sentinel 流量控制的一个核心概念。在 Sentinel 中,几乎所有的资源访问(如 HTTP 请求、数据库调用等)都需要通过创建 Entry 实例来进行。Entry 的创建和退出代表了一次资源访问的开始和结束,Sentinel 会根据这些 Entry 的创建和退出情况来统计流量,并进行相应的流量控制。
  2. SphU(Strictly Priority Hub)类:这是一个常用的工具类,用于快速访问资源,并自动进行流量控制。开发者可以通过调用 SphU.entry(resourceName) 来尝试访问一个资源,如果流量控制规则允许,则返回一个 Entry 对象,表示资源访问被允许;如果不允许,则根据配置进行限流处理(如抛出异常、返回默认值等)。
  3. FlowRuleManager 类:这个类管理着流量控制规则(FlowRule)。在 Sentinel 中,流量控制规则定义了资源的访问阈值、控制策略等。FlowRuleManager 负责加载、更新和查询这些规则,以便在运行时进行流量控制。
  4. ContextUtil 类:这个类用于管理当前线程的上下文信息,如当前线程的调用链信息、来源信息等。这些信息对于 Sentinel 进行精确的流量控制和统计非常重要。
  5. DefaultSlotChainBuilderSlotChain 类:Sentinel 的处理逻辑被封装在一系列插槽(Slot)中,这些插槽按照一定的顺序排列,形成了一个插槽链(SlotChain)。DefaultSlotChainBuilder 负责构建这个插槽链,而 SlotChain 则负责按照顺序执行这些插槽中的逻辑。
java 复制代码
/**
* 每次调用 SphU#entry() 都会返回一个 Entry类。这个类保存了当前调用的信息:
*
*   createTime,这个条目的创建时间,用于响应时间(RT)统计。
*   curNode,即当前上下文中资源的统计信息。
*   originNode,即特定来源的统计信息。通常,来源可以是服务消费者的应用名称,参见
*   ContextUtil#enter(String name, String origin)
*   ResourceWrapper即资源名称。
*   如果在同一个 Context中多次调用 SphU#entry(),则会创建一个调用树,因此这个类可能会持有父条*      目或子条目以形成树状结构。由于 Context 总是持有调用树中的当前条目,因此每次调用 
*      Entry#exit() 时都应该修改
*/
public abstract class Entry implements AutoCloseable {

    protected static final Object[] OBJECTS0 = new Object[0];

    private final long createTimestamp;
    private long completeTimestamp;

    private Node curNode;
    /**
     * 特定来源的Node,通常是服务消费者.
     */
    private Node originNode;

    private Throwable error;
    private BlockException blockError;

    protected final ResourceWrapper resourceWrapper;

    protected final int count;

    protected final Object[] args;

    public Entry(ResourceWrapper resourceWrapper) {
        this(resourceWrapper, 1, OBJECTS0);
    }

    public Entry(ResourceWrapper resourceWrapper, int count, Object[] args) {
        this.resourceWrapper = resourceWrapper;
        this.createTimestamp = TimeUtil.currentTimeMillis();
        this.count = count;
        this.args = args;
    }

    public ResourceWrapper getResourceWrapper() {
        return resourceWrapper;
    }

    /**
     * Complete the current resource entry and restore the entry stack in context.
     * Do not need to carry count or args parameter, initialization does
     * @throws ErrorEntryFreeException if entry in current context does not match current entry
     */
    public void exit() throws ErrorEntryFreeException {
        exit(count, args);
    }

    public void exit(int count) throws ErrorEntryFreeException {
        exit(count, args);
    }

    /**
     * Equivalent to {@link #exit()}. Support try-with-resources since JDK 1.7.
     *
     * @since 1.5.0
     */
    @Override
    public void close() {
        exit();
    }

    /**
     * Exit this entry. This method should invoke if and only if once at the end of the resource protection.
     *
     * @param count tokens to release.
     * @param args extra parameters
     * @throws ErrorEntryFreeException, if {@link Context#getCurEntry()} is not this entry.
     */
    public abstract void exit(int count, Object... args) throws ErrorEntryFreeException;

    /**
     * Exit this entry.
     *
     * @param count tokens to release.
     * @param args extra parameters
     * @return next available entry after exit, that is the parent entry.
     * @throws ErrorEntryFreeException, if {@link Context#getCurEntry()} is not this entry.
     */
    protected abstract Entry trueExit(int count, Object... args) throws ErrorEntryFreeException;

    /**
     * Get related {@link Node} of the parent {@link Entry}.
     *
     * @return
     */
    public abstract Node getLastNode();

    public long getCreateTimestamp() {
        return createTimestamp;
    }

    public long getCompleteTimestamp() {
        return completeTimestamp;
    }

    public Entry setCompleteTimestamp(long completeTimestamp) {
        this.completeTimestamp = completeTimestamp;
        return this;
    }

    public Node getCurNode() {
        return curNode;
    }

    public void setCurNode(Node node) {
        this.curNode = node;
    }

    public BlockException getBlockError() {
        return blockError;
    }

    public Entry setBlockError(BlockException blockError) {
        this.blockError = blockError;
        return this;
    }

    public Throwable getError() {
        return error;
    }

    public void setError(Throwable error) {
        this.error = error;
    }

    /**
     * Get origin {@link Node} of the this {@link Entry}.
     *
     * @return origin {@link Node} of the this {@link Entry}, may be null if no origin specified by
     * {@link ContextUtil#enter(String name, String origin)}.
     */
    public Node getOriginNode() {
        return originNode;
    }

    public void setOriginNode(Node originNode) {
        this.originNode = originNode;
    }

    /**
     * Like {@code CompletableFuture} since JDK 8, it guarantees specified handler
     * is invoked when this entry terminated (exited), no matter it's blocked or permitted.
     * Use it when you did some STATEFUL operations on entries.
     * 
     * @param handler handler function on the invocation terminates
     * @since 1.8.0
     */
    public abstract void whenTerminate(BiConsumer<Context, Entry> handler);
    
}

5、学习实践(未完成)

5.1 限流-令牌桶

注意:令牌桶算法不能与另外一种常见算法"漏桶算法(Leaky Bucket)"相混淆。这两种算法的主要区别在于"漏桶算法"能够强行限制数据的传输速率,而"令牌桶算法"在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在"令牌桶算法"中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它适合于具有突发特性的流量。

令牌桶工作分为三步:

  1. 产生令牌:周期性的以速率CIR/EIR向令牌桶中增加令牌,桶中的令牌不断增多。如果桶中令牌数已到达CBS/EBS,则丢弃多余令牌。
  2. 消耗令牌:输入数据包会消耗桶中的令牌。在网络传输中,数据包的大小通常不一致。大的数据包相较于小的数据包消耗的令牌要多。
  3. 判断是否通过:输入数据包经过令牌桶后的结果包括输出的数据包和丢弃的数据包。当桶中的令牌数量可以满足数据包对令牌的需求,则将数据包输出,否则将其丢弃。

以下模仿在微服务中,1台客户端实例不断请求,3台服务端实例使用令牌桶进行限流的场景。

相关推荐
ᝰꫝꪉꪯꫀ361几秒前
JavaWeb——SpringBoot原理
java·开发语言·后端·springboot
HaiFan.5 分钟前
Spring日志
java·spring boot
爬山算法7 分钟前
Tomcat(36)Tomcat的静态资源缓存
java·缓存·tomcat
TPBoreas8 分钟前
手搓一个不用中间件的分表策略
java
、十一、9 分钟前
Tomcat的工作模式是什么?
java·tomcat
killsime11 分钟前
JavaWeb开发 : tomcat+Servlet+JSP
java·servlet·tomcat·javaweb
LightOfNight21 分钟前
Redis设计与实现第14章 -- 服务器 总结(命令执行器 serverCron函数 初始化)
服务器·数据库·redis·分布式·后端·缓存·中间件
刽子手发艺30 分钟前
云服务器部署springboot项目、云服务器配置JDK、Tomcat
java·后端·部署
北漂编程小王子33 分钟前
maven <scope>import</scope>配置作用
java·maven·maven import
BIGSHU092333 分钟前
java接口对接标准
java