跳至主要內容

xtimer-03-设计剖析

holic-x...大约 20 分钟xtimerxtimer

xtimer-03-设计剖析

学习核心

  • 项目构建(基于模板完成微服务开发:微服务模板应用)=》微服务框架模板说明
  • xtimer 实践
    • 框架模板相关代码结构分析
    • xtimer 代码核心说明
    • xtimer 运行、调试
    • 测试:功能性测试、性能测试
      • 功能性测试:主流程测试(正向流程测试、异常流程测试)、次要流程测试
      • 性能测试(wrk):性能压测、优化点分析
  • xtimer 应用(协同作战)
    • 网关服务、路由转发、用户认证
    • 基于Nacos的服务注册与发现

学习资料

xtimer实践

1.项目核心结构分析

​ 先理解微服务框架的构成,然后拆解每个模块项目的基础结构,基础内容大同小异,此处关注xtimer项目核心

com.noob.xtimer
├── XTimerApplication.java   # 项目启动类
├── common                   # common包:主要存放项目一些共用的内容(例如一些静态配置、公用的线程池配置)
│   ├── conf
│   └── pool
├── controller               # controller包:接口层定义(存放对外提供的web服务接口)
├── enums                    # enums包:项目相关枚举定义
├── exception                # exception包:定义本服务的异常处理(自定义异常类、全局异常处理类等,每个服务都有独立的错误码分配)
├── feign                    # fegin包:微服务接口实现
├── interceptor              # interceptor包:拦截器定义相关
├── manager                  # manager包:通用业务处理层概念
├── mapper                   # mapper包:ORM相关数据库操作
├── model 				    # model包:实体定义
├── redis                    # redis包:抽取一些redis相关的使用逻辑(例如分布式锁、结合业务构建的方法等)
├── service                  # service包:service 业务层定义
└── utils 				    # utils包:项目通用工具类(JSON处理、时间处理等)

target:target文件夹是用来存放项目构建后的文件和目录、jar包、war包、编译的class文件,这些都是maven构建时生成的。如果target目录中没有同步更新目录文件和资源,项目就可能会报错。此时,需要删除target文件夹,然后
maven会再次构建生成

​ 实际项目结构结合个人开发习惯或者团队要求进行调整,主要梳理核心的项目结构,理解每个包的设计,然后结合xtimer的业务流程一步步梳理代码实现

2.xtimer 代码核心

工程框架构建相关

(1)exception

todo(查看 ht 异常处理文档 梳理企业级开发调度异常处理机制)

​ exception包中主要定义了本服务的错误码(ErrCode)、自定义异常类(BusinessException)、以及一个全局的异常处理类(GlobalExceptionHandler)。在一个微服务体系中,每个服务都有独立的错误码分配,通过号段区分不同的微服务模块(例如“定时微服务”的错误码号段就是以9开头),用于后续发生异常报错时可直接跟踪错误码就能知道是哪个服务的问题

(2)interceptor

​ interceptor叫做拦截器,在微服务项目中,拦截器扮演着非常重要的角色。它的原理就是利用Spring的AOP技术,在一个方法的执行前后进行拦截,例如方法执行前可以打印一些日志信息,打印方法参数等。方法执行后可以统一打印执行结果

常用的拦截器功能:

  • 日志记录:记录请求信息的日志,以便进行信息监控、信息统计、计算PV(Page View)等
  • 权限检查:如登录检测,进入处理器检测是否登录
  • 性能监控:通过拦截器在进入处理器之前记录开始时间,在处理完后记录结束时间,从而得到该请求的处理时间
  • 通用行为:读取Cookie得到用户信息并将用户对象放入请求,从而方便后续流程使用,还有如提取Locale.Theme信息等,只要是多个处理器都需要的即可使用拦截器实现

xtimer项目中实现了一个简单的拦截器,叫做Loglnterceptor,用来处理日志的,会在一个请求的前后进行日志打印(例如创建任务请求,会打印其创建任务的参数,最后会打印创建任务成功与否的结果)

(3)pom.xml

pom.xml 是Maven项目的核心配置文件,主要作用包括:

  • 依赖管理:pom.xml 用于管理项目的所有依赖。开发者只需在文件中声明需要的依赖,Maven会自动下载并管理这些依赖
  • 构建配置:pom.xml 包含了构建项目所需的所有配置信息,如编译选项、资源目录、测试目录等
  • 插件配置:Maven通过插件来执行各种任务,如编译、测试、打包等。这些插件的配置也存放在 pom.xml 中
  • 项目信息:pom.xml 还包含了项目的基本信息,如项目名称、版本、描述、开发者信息等
  • 项目管理::pom.xml 还支持多模块项目的构建和管理,可以通过一个父POM来管理多个子模块

xtimer业务相关

(1)controller

controller包中主要放对外提供的web服务接口:

  • XtimerWebController:xtimer中,创建timer、激活timer等操作可以通过微服务调用直接创建外,也可以通过访问对外提供的http接口进行创建,虽然入口不一样但后续执行逻辑都是一样的(与XtimerFeignController对应,分别作为web入口和微服务入口)
  • TestConroller:一个测试接口,用于演示闹钟触发回调,本身不属于xtimer业务逻辑(只作为演示calback逻辑,里面只定义了一个用于测试的callback接口)
(2)fegin

​ 存放基于fegin的一些实现,结合实际业务需求对外提供接口服务

​ 例如xtimer中定义了XtimerFeignController,在该类中实现了创建定时任务、激活定时任务、取消定时任务等接口,用于对外提供微服务接口

(3)manager

manager包通常作为通用业务处理层存在,具有以下几个主要作用:

  • (1)对第三方平台封装的层:负责预处理返回结果及转化异常信息
  • (2)对Service层通用能力的下沉:包括缓存方案、中间件通用处理等
  • (3)与DAO层交互:负责对多个DAO的组合复用

​ 在xtimer项目中,manager包内容也比较简单。因为用户创建的闹钟“定时timer”会周期性的触发,例如每隔5分钟触发一次,而单次触发是通过生成“触发任务task”来跟进的,所以系统中会不断的产生task,这个过程在项目中叫做 migrate。 Migrate的执行逻辑分析如下:

  • (1)将新生成的task任务放入数据库timer task表
  • (2)根据task的触发时间等信息放入redis zset进行定时跟进

​ Migrate是一个通用流程,这个流程会在两个地方触发:

  • (1)调用timer的激活接口时,会触发一次Migrate
  • (2)项目中有个系统定时任务,每隔一段时间(例如2小时),就会触发一次全局所有timer的Migrate

​ 既然是个通用流程,因此此处将其抽成一个manager也是合理的

(4)service

​ service包是项目的核心逻辑实现,xtimer定时微服务项目的执行流程分成了多个模块:scheduler、trigger、executor、 migrator等,每个模块的具体处理逻辑实现放在service包中

(5)mapper(mapper接口、mapper.xml映射)

​ mapper是mybatis中的一个概念,里面定义的就是数据库的相关操作。xtimer中主要有两张表,分别是“timer”和“timer task”,所以这里定义了两个对应的mapper接口。 在mapper定义好各种数据库操作的接口之后,还需要完成对应的实现逻辑,这个逻辑是通过在xml文件中进行定义的,文件一般放在resource目录下面

(6)redis

​ redis包主要抽取一些redis相关的使用逻辑,例如分布式锁,zset维护task信息等逻辑

(7)model

​ model包主要放一些数据模型(此处存放的规则结合实际开发进行设计,可以是依据个人开发习惯,也可以是团队开发规范),常见由两种拆分模式

例如在一个业务模块中可以将model拆分为多种数据模型存放目录:

  • model

    • entity:实体对象(与数据库实体对照,例如xtimer中数据库表对应的数据模型TimerModel、TaskModel)

    • dto:数据传输对象(一般用于接口交互请求参数定义,例如xxxRequest)

    • vo:视图对象(可以理解为经由包装处理或要响应给业务的对象)

    • enums:枚举定义(enums包中主要放项目中用到的各种枚举值,以xtimer为例主要有timerStatus、taskStatus)

      • TimerStatus:主要是标识用户创建的定时任务是激活状态还是去激活状态(相当于手机里闹钟的开关)
      • TaskStatus:一个周期性的闹钟timer最终会生成成一个个的单次触发的任务task,这个task也有自己的状态例如未执行、执行中、执行成功、执行失败等状态
    • constants:模块相关常量定义

在一些项目设计中,也会将这些单独拆出来作为同级概念,其实结合开发规范存放即可,至于放在哪个位置,给它一个规范支撑,按照这个规范执行即可

  • mode
  • enums
  • constants
  • ......

xtimer 代码核心梳理(🚀)

目前只实现了核心接口,创建任务和激活任务,删除这种可以自行实现(操作数据库)

看代码不要分模块看,要分几个流程看,顺着入口一条线理解相应的流程,关注几个核心执行流程的完整实现即可,主要是4点内容:

  • (1)创建任务接口:timer 如何创建的

    • XtimerWebController#createTimer
  • (2)激活任务接口:timer 如何激活的(激活流程的完整链路),理解timer对应的第一批 task 如何迁移生成的

    • XtimerWebController#enableTimer
  • (3)定时调度模块 scheduler:理解一个任务 task 如何调度、触发、执行的,完整流程会串联schedule调度模块、trigger触发模块,excutor执行模块

    • ScheduleWorker#scheduledTask
  • (4)迁移模块 migrator:分析migrator定时脚本的运行流程,理解task任务是如何定时生成出来的后

    • MigratorWorker#work

3.xtimer 定时微服务业务流程梳理(运行、调试)

xtimer运行

数据库初始化

/*Table structure for table `xtimer` */
DROP TABLE IF EXISTS `xtimer`;

CREATE TABLE `xtimer` (
                           `timer_id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'TimerId',
                           `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
                           `modify_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
                           `app` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'app',
                           `name` varchar(256) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'name',
                           `status` tinyint NOT NULL DEFAULT '0' COMMENT '0新建,1激活,2未激活',
                           `cron` varchar(256) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'cron表达式',
                           `notify_http_param` varchar(8192)  COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回调上下文',
                           PRIMARY KEY (`timer_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Timer 信息';


/*Table structure for table `timer_task` */
DROP TABLE IF EXISTS `timer_task`;

CREATE TABLE `timer_task` (
                         `task_id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'taskId',
                         `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
                         `modify_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
                         `timer_id` bigint unsigned NOT NULL COMMENT 'TimerId',
                         `app` varchar(128) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'app',
                         `output` varchar(1028) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT 'output',
                         `status` tinyint NOT NULL DEFAULT '0' COMMENT '0新建,1激活,2未激活',
                         `run_timer` bigint COMMENT '运行时间',
                         `cost_time` bigint unsigned NOT NULL COMMENT '执行耗时',
                         PRIMARY KEY (`task_id`) USING BTREE,
                         UNIQUE KEY `idx_timer_id_run_timer` (`timer_id`,`run_timer`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Timer Task任务信息';

中间件环境构建、配置

  • nacos
  • mysql
  • redis
spring:
  application:
    name: @artifactId@
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        username: nacos
        password: nacos
        
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/db_svr_xtimer?serverTimezone=GMT%2B8&characterEncoding=utf8&&allowMultiQueries=true&useSSL=false
    username: root
    password: 123456
    type: com.alibaba.druid.pool.DruidDataSource

  redis:
    host: localhost
    port: 6379
    password: root2023
    database: 0
    jedis:
      pool:
        max-active: 8 #最大连接数

启动测试

​ 启动xtimer,测试功能,结合业务流程梳理功能实现

4.xtimer 测试

功能性测试

​ 作为后端开发,可以将测试、粗略分为功能性测试和性能测试。功能性测试即测试项目中的各个功能是否正常,它可以分为主流程测试、次要流程测试

  • 功能性测试:
    • 正向流程测试:以正确的输入看程序反馈是否正常
    • 异常流程测试:故意填一些有问题的数据,来观察系统是否能正常响应,以及是否能有预期的一些错误提示

​ 此处主要还是聚焦于主功能的正向流程测试,通过正向流程测试可以验证主功能是否正常

接口测试:创建定时任务“激活”接口“去激活”接口

(1)创建定时任务

​ 就像设置一个闹钟一样,要使用定时微服务肯定首先需要创建闹钟,闹钟的触发时机用crontab表达式来指定

Cron 表达式(在线Cron表达式生成器open in new window

​ Cron表达式是一个字符串,用于在指定的时间间隔内运行程序。它由6或7个字段组成,每个字段通过空格分隔,代表不同的时间单位。

​ 这些字段从左到右依次为:秒(0-59)、分钟(0-59)、小时(0-23)、日期(1-31)、月份(1-12 或 JAN-DEC)、星期几(0-7,其中0和7都表示周日,或者使用SUN-SAT表示)、年份(可选字段,范围从1970-2099)

​ 每个字段可以包含特定的数值、范围(用中划线“-”表示)、列表(用逗号“”表示)以及间隔(用斜杠“”表示):

​ 星号(*)在Cron表达式中表示所有可能的值(例如,在分钟字段中,星号(*)表示“每分钟”)

​ 问号(?)可用于日或星期字段中,表示不指定值

​ Cron表达式的配置简洁方便,因此在定时调度任务中被广泛使用。

​ 例如,表达式"0 0 * * * *"表示程序将在每小时的第0分钟开始执行,即每个小时的开始时执行。而"0 0*5 * * * *"则表示程序将每5分钟执行一次

curl 发送请求:

curl --location 'http://127.0.0.1:8082/xtimer/createTimer' \
--header 'Content-Type:application/json' \
--data '{
	"app":"testXTimer",
	"name":"测试XTimer",
	"cron":"*/5 * * ? * *",
	"notifyHTTPParam":{
		"url":"http://127.0.0.1:8082/xtimer/callback",
		"method":"POST",
		"body":"its time on. this is a call msg"
	}
}'

响应结果:

{"code":0,"message":null,"datetime":"2024-08-16T18:03:45.938","data":1}
(2)“激活”接口

​ 激活任务:定时任务(闹钟)创建好之后会处于待激活状态,待激活状态的任务并不会执行。所以需要再调用一个激活接口来激活具体的定时任务。可以理解为手机里创建好一个闹钟之后,闹钟是可以开启(“激活”)或关闭(“去激活”)的

curl 发送请求:

curl --location 'http://127.0.0.1:8082/xtimer/enableTimer?app=testXTimer&timerId=1'

响应结果:

{"code":0,"message":null,"datetime":"2024-08-16T18:06:29.907","data":"ok"}
(3)“去激活”接口

​ “去激活”接口:这个接口和激活接口就是两个配套的接口,用来开关定时任务

扩展问题

​ “去激活”接口的实现思路:去激活后是否会对现有已生成的任务有影响?

​ 去激活操作对现有任务有影响:如果去激活操作执行,其关联任务会受到影响,有两种逻辑实现:

  • 方向1:去激活操作执行,联动将现有任务执行状态进行修改(但是这种情况需要考虑可能状态还没来得及修改就已经执行了)
  • 方向2:去激活操作执行,不会对现有任务做操作,任务会正常调度,但是最终任务执行的时候会检查“闹钟”是否处于激活状态,如果不是则不执行(即不回调业务方)

​ 框架现有实现选择的方向是方向2,去激活操作只改变xtimer的开关状态,任务会正常调度,但是任务在执行阶段会做一个判断(如果闹钟状态是关闭则不执行)

性能测试

(1)前置准备

​ 使用wrk进行性能测试(对比优化前后的性能测试:引入druid数据库连接池进行优化)

  • wrkbench/xtimer_bench.sh(执行指定lua脚本的wrk测试文件)
  • wrkbench/xtimer_create_timer.lua(wrk压测执行的lua脚本)
# wrkbench/xtimer_bench.sh
wrk -t50 -c200 -d30s --script=xtimer_create_timer.lua --latency "http://127.0.0.1:8082/xtimer/createTimer"
# wrkbench/xtimer_bench.sh
wrk.method = "POST"

wrk.body = "{\"app\":\"testXtimer\",\"name\":\"测试Xtimer\",\"cron\":\"0 * * ? * *\",\"notifyHTTPParam\":{\"url\":\"http://127.0.0.1:8082/xtimer/callback\",\"method\":\"POST\",\"body\":\" its time on. this is a callback msg\"}}"

wrk.headers["Content-Type"] = "application/json"

function request()
        return wrk.format('POST',nil,headers,body)
end

​ 压测过程中需要观察timer的数量,因此压测之前需要将xtimer表中的数据清空,便于后续观察:truncate table xtimer

(2)优化前压测

​ 执行压测脚本:sh xtimer_bench.sh,查看并分析压测结果

​ 查看成功创建的xtimer数量:select count(*) from xtimer

(3)优化后压测

​ 引入druid数据库连接池进行优化

依赖:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.15</version>
</dependency>

配置:

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/bitstorm-svr?serverTimezone=GMT%2B8&characterEncoding=utf8&&allowMultiQueries=true&useSSL=false
    username: root
    password: root@2023
    type: com.alibaba.druid.pool.DruidDataSource
    # druid 核心配置
    druid:
      min-idle: 1                                          # 最小连接数
      max-active: 100                                         # 最大连接数(默认8)
      max-wait: 1000                                       # 获取连接时的最大等待时间
      min-evictable-idle-time-millis: 300000               # 一个连接在池中最小生存的时间,单位是毫秒
      time-between-eviction-runs-millis: 60000             # 多久才进行一次检测需要关闭的空闲连接,单位是毫秒
  • 压测结果数据会受到各方面的影响,最重要的是控制变量观察分析压测结果
  • 连接池配置参数的最优性:连接池配置参数的选择要结合实际业务场景去调试、择优,通过观察压测性能找到一个最佳性能对应配置(但真实场景中,一般找到一个相对较优的值即可,执着于找最优的意义和作用的相对提升并不会差多少)
  • 为什么只对“创建timer的接口”做性能压测?有没有对其他部分进行压测?
    • 针对数据库的优化是引入了druid数据库连接池,针对“创建timer的接口”做性能压测实际上就是对数据库的性能压测,其他的接口也是类似的业务逻辑处理、数据库操作相关,虽然不同的业务处理逻辑和耗时都不同,但针对数据库操作层面优化提升的效果是类似的。

xtimer 应用

​ 项目设计是基于微服务体系的,这些微服务的访问总要以某种方式暴露出来,在测试阶段,可以直接通过访问微服务端口访问,但实际真正上线后,内部这些服务都是禁止外部直接进行访问的,必须有一个统一的地方作为流量的入口(网关),网关的作用除了统一入口之外,还提供鉴权、路由转发等功能。此处项目的网关层服务则是基于Spring Cloud Gateway实现的。

Spring Cloud Gateway

​ Spring Cloud Gateway是Spring Cloud的一个全新项目,该项目是基于Spring 5.0、Spring Boot 2.0和Project Reactor等技术开发的网关,它旨在为微服务架构提供一种简单有效的统一的API路由管理方式,Spring Cloud Gateway作为Spring Cloud生态系统中的网关,目标是替代Zuul,其不仅提供统一的路由方式,并且基于Filter链的方式提供了网关基本的功能,例如:安全、监控/指标、限流等。

​ Spring Cloud Gateway的主要特点包括:

  • (1)基于异步非阻塞的Reactor框架实现的响应式编程模型,具有高性能、高吞吐量和低延迟的优势
  • (2)支持多种路由策略,包括基于路径、请求参数、请求头、主机等的路由
  • (3)支持多种过滤器,包括预置的全局过滤器和自定义的局部过滤器,用于实现请求转发、请求修改、请求日志、请求验证、请求缓存等功能
  • (4)支持动态路由配置和动态过滤器配置,可以在运行时动态添加、修改和删除路由和过滤器
  • (5)支持集成Spring Cloud Discovery,可以自动从注册中心获取微服务实例信息进行路由转发
  • (6)支持集成Spring Cloud Security,可以实现身份认证、授权和安全限制等功能
  • (7)支持集成Spring Cloud Circuit Breaker,可以实现服务熔断、降级和恢复等功能
  • (8)支持集成Spring Cloud Stream,可以实现异步消息处理和事件驱动架构等功能

微服务体系

​ 模拟通过统一的鉴权、网关访问微服务系统,相应需要运行鉴权和网关两个服务

  • 鉴权系统:svr-auth
  • 网关系统:svr-gateway

1.鉴权服务

​ 鉴权的方式有很多,此处采用无状态jwt的方式进行鉴权,通过账号密码进行登录,验证通过之后,鉴权系统会根据用户信息生成token返回给调用方。后续调用方访问的时候,在Header携带这个token,网关会计算这个token的合法性,计算出其中的用户ID,然后可以将这个用户ID传递给更后面的服务:

  • (1)从auth服务登录并获取token
  • (2)通过gateway网关服务进行token鉴权

获取token

curl --location 'http://127.0.0.1:10001/auth/token' \
--header 'Content-Type:application/json' \
--data '{
	"username":"noob",
	"password":"123456"
}'

​ 请求响应返回token信息给调用方

微服务请求

​ 在后续的调用操作中,需要携带这个token。例如访问svr-demo服务的测试接口(hello接口)

curl --location 'http://127.0.0.1:10001/demo/hello' \
--header 'Authorization: xxxxx' \
--data ''

​ 请求响应结果:

# 正常响应可直接调用


# 如果token校验失败则会提示错误


# 如果请求没有带token则会提示错误

2.路由转发

​ 网关服务是基于SpringCloud Gateway实现的,关于所有的路由转发等配置信息都维护在Gateway服务的application.yml中。

spring:
  application:
    name: @artifactId@
  cloud:
    nacos:
      discovery:
        server-addr: 192.168.5.53:8848
        username: nacos
        password: nacos

    gateway:
      routes: # 网关路由配置
        - id: svr-auth # 路由id,自定义,只要唯一即可
          # uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址
          uri: lb://svr-auth # 路由的目标地址 lb就是负载均衡,后面跟服务名称
          predicates: # 路由断言,也就是判断请求是否符合路由规则的条件
            - Path=/auth/** # 这个是按照路径匹配,只要以/user/开头就符合要求
        - id: svr-xtimer # 路由id,自定义,只要唯一即可
          # uri: http://127.0.0.1:8081 # 路由的目标地址 http就是固定地址
          uri: lb://svr-xtimer
          predicates:
            - Path=/xtimer/**
          filters:
            - RewritePath=/xtimer/(?<segment>.*), /$\{segment}
        - id: svr-testconsumer
          uri: lb://svr-demo
          predicates:
            - Path=/demo/**
          filters:
            - AuthenticationFilter

Gateway可以转发http 请求,也可以转发feign接口的请求

  • id:代表服务名称,唯一即可
  • uri:uri即目标服务的地址,此处可以使用CLB负载均衡,后面接服务名称。gateway会自动从nacos中获取可用的服务实例进行转发,所以这里的服务名称需要与注册到nacos中的服务名称对应上,才能完成转发
  • predicates:筛选目标请求
  • filters:过滤器集合,即当把目标请求通过筛选出来后,可以为这些请求添加相应的处理操作,例如鉴权操作,或者对请求参数进行调整更改等都可以在各种filter中完成
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3