Skip to content

后端工程 ​

这一页讲一个应用的后端部分:工程放哪、依赖怎么声明、怎么构建,哪些代码必须自己写、 哪些由宿主在运行期替你注册,以及「用宿主能力 / 跨插件调用 / 开放接口 / 定时任务 / 事件」 这五件最容易做错的事。

写法以范例应用 wujies-hello 为准(后端 <插件仓库>/wujies-hello/,前端 frontend/hello-app/)。 它的源码就是"已经这么做并且能跑"的最小实例;涉及具体写法时先打开它对照,再回来看本页。 按主题逐文件索引见 跟着 hello 读源码。

0. 三个仓库与三条被推翻的旧规则 ​

仓库内容本页涉及的路径
wujies-platform/后端(Java / Spring Boot)宿主:wujies-admin/、wujies-common/、wujies-modules/
wujies/wujies-plugins/插件本体 + 插件前端工程 + 打包器wujies-<app>/、frontend/<app>-app/、package.ps1
plus-ui/宿主前端(Vue 3 / Vite)⚠️ 它的 package.json 里 name 写的是 wujies-admin,别被名字骗了

先纠正三条旧规则

  • 平台里没有 *-api 契约模块(数量为 0)。每个应用是自包含的一个工程:实体 / BO / VO / Mapper 接口 与 Mapper XML / Service 接口与实现 / controller / open / listener / task / app.json / migrations/ 全在插件仓库自己的目录下。
  • Mapper XML 不放平台、也不靠 mapperLocations 扫出来 —— 它随插件 jar 走, 由 PluginMapperRegistrar 在加载时动态注册(见 §4)。
  • wujies-platform 的在线代码对 cn.wujies.<app>.* 的 Java 引用为 0; 只有归档目录 wujies-platform/docs/removed/ 里还留着历史残留。

一句话:新增一个业务模块不需要动平台一行,只写插件。这是整套外置架构的全部意义。

1. 工程位置与目录结构 ​

一个应用 = 插件仓库里的一个工程,它不在平台的 Maven reactor 里:

wujies/wujies-plugins/                 # 插件仓库:独立 git 仓库、独立父 pom
├── pom.xml                            # cn.wujies.plugins:wujies-plugins-parent
├── package.ps1                        # 打包 + 签名
├── sandbox/                           # 本地调试用的宿主沙箱(H2 内存库)
└── wujies-hello/
    ├── app.json                       # 清单:编码 / 版本 / 产物路径 / 菜单树
    ├── pom.xml
    ├── migrations/                    # 建表、种子数据、字典、站点配置(随包走)
    └── src/main/
        ├── java/cn/wujies/hello/
        │   ├── domain/                # 实体(另有 bo/ 入参、vo/ 出参)
        │   ├── mapper/                # Mapper 接口
        │   ├── service/               # I*Service 接口
        │   │   └── impl/              # 实现
        │   ├── controller/            # 管理端接口
        │   ├── open/                  # 公开接口(/open/<app>/**)
        │   └── task/                  # 定时任务
        └── resources/mapper/hello/    # 有自定义 SQL 时才需要放 Mapper XML

各目录的职责与真实文件对照:

目录(包名 cn.wujies.<app>.)放什么wujies-hello 里的对应文件
domain/、domain/bo/、domain/vo/实体、入参、出参domain/HelloItem.java
mapper/Mapper 接口mapper/HelloItemMapper.java
src/main/resources/mapper/<app>/自定义 SQL 的 Mapper XMLhello 只用 MyBatis-Plus 通用方法,没有 XML;现成例子见 wujies-gallery/src/main/resources/mapper/gallery/*.xml
service/、service/impl/服务接口与实现service/HelloService.java、service/impl/HelloServiceImpl.java
controller/管理端接口controller/HelloController.java(GET /hello/item/list)
open/公开接口 /open/<app>/** + 类上 @SaIgnoreopen/HelloDemoOpenController.java
task/定时任务(两种写法)task/HelloDemoScheduledTask.java、task/HelloDemoSnailJobTask.java
listener/、event/、message/消息消费者与事件hello 没有;见 wujies-gallery/src/main/java/cn/wujies/gallery/listener/
enums/、constant/枚举与常量wujies-gallery 的 constant/

包名是硬约定:cn.wujies.<app>.

PluginMapperRegistrar 在重新注册前会按包名前缀扫除上一轮留下的语句、ResultMap 与"已加载"标记, 前缀直接由 appCode 推出(cn.wujies.<app>.)。包名跑偏,清理就会漏 —— 症状是热加载后 Invalid bound statement (not found)。

2. 依赖:平台件一律 provided ​

wujies-hello/pom.xml 的父 POM 与依赖声明(节选):

xml
<parent>
    <groupId>cn.wujies.plugins</groupId>
    <artifactId>wujies-plugins-parent</artifactId>
    <version>1.0.0</version>
    <relativePath>../pom.xml</relativePath>
</parent>

<artifactId>wujies-hello-plugin</artifactId>

按用途声明平台依赖(父 POM 的 dependencyManagement 里已定好版本与 scope):

用途artifactId
R / 基础工具 / ConfigService / OssServicewujies-common-core
Web(BaseController)wujies-common-web
MyBatis-Plus 注解与通用方法wujies-common-mybatis
登录态(LoginHelper)wujies-common-satoken
跨插件契约与定位器wujies-common-plugin-sdk
MyBatis 之外的常用能力wujies-common-redis、wujies-common-tenant、wujies-common-oss、wujies-common-log、wujies-common-rabbitmq 等
兄弟插件的契约wujies-<sibling>-plugin(需要时显式写 provided,见 §6)
SnailJob 注解与 DTOsnail-job-client-starter、snail-job-client-job-core(版本由父 POM 管,不写 version)

子模块里通常不用逐条写 <scope>provided</scope>

插件仓库父 POM 的 dependencyManagement 已经把平台件统一设成 provided。 但你必须知道它得是 provided:一旦有人改成 compile,平台类会被打进插件 jar, 出现「同一个接口两个 Class 对象」—— instanceof 莫名返回 false、按类型注入找不到 bean, 而且只在运行期暴露。

  • 不要依赖 wujies-system:父 POM 的 dependencyManagement 里刻意没有它。 宿主能力走 wujies-common-* 的接口(见 §5)。 验收方式很直接:若还有插件引用 cn.wujies.system.*,编译会 cannot find symbol。
  • 第三方 SDK 也是 provided:支付宝 / 微信支付 / 微信 MP 由宿主的 wujies-admin 显式引入, 插件只编译期引用(打进插件要 shade,会撞 BouncyCastle 的签名校验,包也会从几十 KB 涨到几十 MB)。

别动 plugin.contract.artifact

父 POM 里这个属性默认是哨兵值 none,机制保留但当前无人在用。 把它设成空串会让 Maven 解包全部依赖到 target/contract,再被 antrun 的 **/mapper/**/*.class 挑进插件 jar —— 实测把 BaseMapperPlus 之类的平台类打进了插件, 直接违反"平台件必须 provided"。

3. 构建、打包、跑起来 ​

powershell
# ① 平台构件先装进本地仓库(插件以"已发布的平台构件"形式引用平台,不拉平台源码)
cd wujies-platform
& $mvn -pl wujies-admin -am -B install -DskipTests

# ② 构建插件(在插件仓库根,用 -pl 指定应用)
cd ..\wujies\wujies-plugins
& $mvn -B -pl wujies-hello package -DskipTests
#   产物:wujies-hello/target/wujies-hello-plugin-1.0.0.jar

# ③ 打成应用包(默认取该应用的 migrations/,按 app.json 组包,可选签名)
.\package.ps1 -Plugin wujies-hello -SigningKey keys/private.pem
环节要点
平台 install平台版本由父 POM 的 platform.version 统一控制;构件解析不到时报 dependencies.dependency.version is missing,回去补第 ① 步
插件构建只编译这一个应用;构建前必须已 install 过平台构件
打包package.ps1 的信息全部来自 app.json,路径按 code 推导;发布与签名见 打包与发布、包签名

新插件必须挂进插件仓库根 pom 的 <modules>

漏了这一步的表现是"改了代码没生效",很难第一时间想到。wujies-hello 就在列表最前面。

改了后端代码必须先重新出 jar

只跑 mvn compile(哪怕成功)不会更新可运行 jar,你会拿着旧代码得出"功能没生效"的结论, 然后去改本来是对的实现。另外:不要在正在运行的 JVM 上跑 mvn install —— compiler 插件会先清空输出目录再全编,正在运行、按需懒解析类的 JVM 会抛 NoClassDefFoundError。

本地跑起来不需要 MySQL / Redis / RabbitMQ —— 沙箱用 H2 内存库:

powershell
& $mvn -B -pl sandbox spring-boot:run `
  "-Dspring-boot.run.arguments=--sandbox.plugin-jar=../wujies-hello/target/wujies-hello-plugin-1.0.0.jar"

先打 GET /hello/greet(验装载与依赖注入,不碰库),再打 GET /hello/item/list (验建表脚本、Mapper 与 SQL);/sandbox 看插件装上了没有、挂了几条路由。 两条都通说明链路完整,只有第二条不通说明问题在数据侧(建表脚本 / Mapper / 表名)。 清单里的应用则要在控制台部署后用真实宿主联调,见 本地联调。

4. 你不需要写的注册代码 ​

东西谁注册约定
Spring 组件PluginRuntimeManager.scanComponents枚举 jar 条目、按注解收集;@Service / @Repository / @RestController / @Configuration 都元注解了 @Component,一次判断就够;跳过接口、抽象类、内部类($)
Mapper 接口PluginMapperRegistrar带 @Mapper,或包名含 .mapper
Mapper XML同上jar 内路径以 mapper/ 开头、以 .xml 结尾
Mapper 的 Spring beanPluginRuntimeManager注册进宿主 bean factory,兄弟插件才能注入(见 §6)
HTTP 路由PluginHandlerMapping从子上下文取 @Controller bean 注册,并复制宿主的拦截器链
@Scheduled / @RabbitListener子上下文基础设施PluginRuntimeManager 给子上下文直接注册 BeanPostProcessor(消息监听还配一个子上下文自己的 RabbitListenerEndpointRegistry);不要自己写 @EnableScheduling / @EnableRabbit

不需要写:@ComponentScan、@MapperScan、spring.factories —— 插件运行时是按注解扫 jar 条目注册 bean 的,不看 spring.factories。

@Async 不在这张表里

子上下文只补了 @Scheduled 与 @RabbitListener 这两类基础设施, 别默认插件里的 @Async 会自动生效 —— 要用异步就先确认宿主那一侧到底注册了什么。

关于 Mapper XML,四件事必须记住:

  1. 路径就是 src/main/resources/mapper/<app>/*.xml(打进 jar 后是 mapper/...xml)。
  2. 它不是靠 mapperLocations 扫出来的。宿主那份 mapperLocations: classpath*:mapper/**/*Mapper.xml(见 wujies-admin/src/main/resources/application.yml) 用的是宿主类加载器,扫不到插件里的 XML;而拿它去扫会把宿主的映射文件再注册一遍, 直接撞 Result Maps collection already contains value,宿主起不来。
  3. 注册是代码做的:PluginMapperRegistrar 只读传入的那个 jar,先解析 XML、再 addMapper (MyBatis 的既有约定,顺序不能反)。
  4. XML 里的 namespace 与 type 写全限定名,例如 cn.wujies.gallery.mapper.GalleryImageMapper、cn.wujies.gallery.domain.vo.GalleryImageVo —— 不要依赖宿主的 typeAliasesPackage。

一个写坏的 XML 不会拖垮整个插件

每个 XML 独立 try/catch,失败会 WARN 并继续(只影响那个 Mapper 的方法), 但异常仍向上抛 —— 这是刻意保留的"硬失败",避免出现"插件装上了、所有 SQL 报不存在"的假成功。

依赖不在宿主类路径上的类会被跳过

scanComponents 在注册前先做一次"能不能链接"的检查:连不上就跳过并 WARN(列出缺哪个包)。 所以带 @JobExecutor / @RabbitListener 的可选能力,在没引对应库的宿主上只是不生效, 不会让整个插件装不上。

队列 / 交换机 / 绑定要声明在宿主侧

子上下文里声明的 Queue / Exchange / Binding,宿主的 RabbitAdmin 看不见、也不会声明到 broker —— 表现是"插件加载成功、消息发不出去 / 消费者起不来"。本项目的做法是放在宿主 wujies-common-rabbitmq 的 RabbitMQConfig;插件里的 @RabbitListener 本身没问题。

5. 插件怎么用宿主能力 ​

插件上下文是宿主上下文的子上下文(宿主是 parent),所以插件能注入宿主 bean,反之不行 (Spring 父子关系是单向的,见 §10)。

wujies-hello 演示了最常用的三个,源码在 wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoHostController.java:

能力接口演示端点
读平台配置表 sys_configConfigServiceGET /hello/demo/host/config
把 sys_oss 里的行 id 换成可访问 URLOssServiceGET /hello/demo/host/oss-url
读当前登录用户(宿主建立的登录态)LoginHelperGET /hello/demo/host/whoami
  • 用构造注入(@RequiredArgsConstructor),不要字段注入:缺哪个宿主能力会在启动期暴露, 而不是某次调用才 NPE。
  • 可选能力用 ObjectProvider<T> 取,宿主没提供就降级,而不是启动失败。
  • 插件不要自己拼 OSS 地址、也不要自己解析 token —— 这两件事都由宿主能力负责。

平台 SPI 的实现必须留在宿主侧

如果某个 SPI 的消费方可能是另一个应用,实现要放在宿主侧的平台公共模块(wujies-common-*), 不能放在插件里。原因:插件只看得见父上下文(宿主),看不见兄弟插件 —— 实现放在插件 A 里,插件 B 通过 ObjectProvider 取到的是 null,功能静默降级。

6. 跨插件调用:类可见 ≠ bean 可见 ​

这是本项目最容易被做错的一处。文档里两句话看着矛盾,其实都对:

层面能不能跨插件为什么
类(编译期 / 类加载期)能PluginClassLoader.findClass 在"从自己的 URL 定义"之前,先按包名把类委托给拥有它的那个插件加载器(cn.wujies.member.* 一律交给 member 的加载器)—— 所以 JVM 里同一个 FQCN 只有一个 Class 对象,instanceof 成立
Spring bean(运行期)不能每个插件有自己的子上下文(parent 是宿主),你的上下文里没有兄弟的 bean

所以在 pom 里依赖兄弟插件构件买到的只是类型可见,不是 bean 可见。落地成两条:

要用的东西能直接注入吗怎么做
兄弟的 Mapper 接口能PluginMapperRegistrar 把它注册进宿主 bean factory ⇒ 宿主 bean 对子上下文可见
兄弟的 Service bean不能用平台 SDK 的 XxxServiceLocator(每次调用都反查,装卸、升级都跟得上)
兄弟的 DTO / 事件类—(不是 bean)provided 依赖引用类型即可

定位器有两种用法:

场景写法
可选依赖(没装也能跑,只是少个功能)先 locator.available(IMemberService.class) 判在不在,不在就优雅降级
强依赖(没装这功能没意义)直接 locator.get(IMemberService.class),让它抛「请到应用市场安装 XX」,比 NPE 清楚

现成的三个定位器住在平台 SDK wujies-common-plugin-sdk: MemberServiceLocator / PayServiceLocator / ImServiceLocator, 由该模块的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 装配。 两种做法都能跑的对照代码见 wujies-hello/src/main/java/cn/wujies/hello/controller/HelloDemoCrossPluginController.java (/hello/demo/cross/mapper 用兄弟的 Mapper,/hello/demo/cross/service 走定位器并降级)。

跨插件共享的类必须住在平台段

定位器曾经被放在插件自己的 fallback 包里,结果宿主与别的插件都拿不到它, 报 No qualifying bean of type '...MemberServiceLocator'。判断标准是「谁会引用它」, 而不是"它看起来更像谁的一部分"。平台段 = wujies-common-* / wujies-common-plugin-sdk。

依赖兄弟插件构件之前想清楚

兄弟插件没装时,引用它的类解析不到,scanComponents 会跳过相关类并 WARN「缺 cn.wujies.x.*」。 所以"可选依赖"请走定位器,别在字段类型上强绑。

7. 从插件里开放 /open 接口 ​

这是"应用外置"之后最常被问的一件事:接口写在插件里、宿主管不着它,C 端 / 第三方怎么调? 答案:写一个 /open/<app>/** 的控制器,宿主一行都不用改。

#做什么为什么 / 谁负责
1控制器放 /open/<app>/**,类上加 @SaIgnore宿主路由统一过 Sa-Token 的登录检查(路径清单来自 AllUrlHandler,插件路由也在其中),@SaIgnore 就是"这条不要登录态"
2什么都不用注册宿主的 AllUrlHandler 会同时收集「宿主 mapping + 插件 mapping」,PluginRuntimeManager 在 load / unload 之后调 refresh() —— 装了就进清单、卸了就出清单
3租户自动就有宿主 open-api.tenant.path-patterns 的默认值含 /open/**,按请求头把租户设进上下文;不设的话带租户过滤的表查不到数据
4站点配置不用自己写接口把 <code>.* 写进 sys_config(config_category = 应用编码,见 种子数据与站点配置),宿主的泛化端点 GET /open/<code>/config 直接能读

第 4 条多说一句:那个泛化端点实现在宿主 wujies-system 的 OpenWebsiteConfigController, 它只认 config_category、不认识任何具体应用 —— 返回里 8 个基础字段 (logo / favicon / name / nameEn / backgroundImage / description / copyright / icp) 各占一个属性,其余 <code>.* 去掉前缀后原样放进 settings 透传给前端。 也就是说,"新应用要有一个公开的站点配置接口"这件事你什么都不用做。

别自己再包一层站点配置接口

HelloDemoOpenController 里的 GET /open/hello/config-via-plugin 是反面教材: 同样的数据用"插件自己读 sys_config 再包一层"又实现了一遍。别照它写 —— 它的存在只是为了让你看到"这件事宿主已经替所有应用做完了"。

这些路由只在装载时存在

没装该应用的部署,/open/<app>/** 就是 404 —— 不会留下"能路由、一调就报没装"的空壳。

8. 定时任务:两种写法都合法 ​

wujies-hello 两个都写了,因为两种都是真实存在的做法(不是二选一的历史遗留):

@Scheduled(HelloDemoScheduledTask)SnailJob @JobExecutor(HelloDemoSnailJobTask)
cron 写在哪代码里(注解参数)控制台里(任务调度中心)
改了 cron 要重启吗要(重新打包 + 装载)不要
执行历史 / 重试 / 告警只有日志有(控制台能看到每次执行)
多实例部署每台都跑(要自己加分布式锁)由调度中心分片 / 路由
额外依赖不需要(Spring 自带)需要 SnailJob 客户端
适合什么本机内务、轻量高频、失败重跑就行业务任务:要对账、要可观测、要能临时改时间

三条只有真做过才知道的事:

  1. @Scheduled 在插件里能用,因为 PluginRuntimeManager 在建立插件上下文时直接注册了 ScheduledAnnotationBeanPostProcessor(源码注释写明:"刻意不注册带 @EnableScheduling 的 @Configuration 类")。你不要自己写 @EnableScheduling。
  2. @JobExecutor 光有注解不会跑 —— 还要在调度中心建一条任务,执行器名必须与 @JobExecutor(name=...) 完全一致;方法签名固定为 public ExecuteResult jobExecute(JobArgs jobArgs)。只把包传上去、不建任务,是不会有任何执行的 —— 排查"任务没跑"的第一件事是这个,不是权限、也不是插件没装。
  3. SnailJob 的日志要用 SnailJobLog.REMOTE:普通 log.info 只进本机文件, 在控制台的任务日志里看不到。

9. 事件:先想清楚方向 ​

方向成立?机制
插件 → 宿主成立插件 publishEvent 会冒泡到父上下文(Spring 自带行为)
宿主 / 兄弟插件 → 插件由中继转发PluginEventRelay(宿主侧)把宿主收到的事件转发进各插件子上下文

事件类必须在父加载器上,否则静默失效

订阅方的 @EventListener(PaySuccessEvent.class) 认的是自己类加载器里的那个 Class 对象。 事件类若跟着发布方插件的 jar 走,订阅方就拿到第二个 Class → 永远不匹配,而且不报任何错。 所以事件类统一放 wujies-common-core 的 cn.wujies.common.core.domain.event (现有 UserRegisteredEvent / PaySuccessEvent / PayRefundSuccessEvent / ProcessEvent 等)。

事件里只放标量(扁平 POJO):wujies-common-core 不能依赖 wujies-common-tenant(会成环), 所以不要塞继承 TenantEntity 的实体 —— 需要实体就带 ID / 单号,由接收方自己重查。

10. 宿主侧要用插件的服务 ​

宿主注入不到插件(Spring 父子关系单向),只有两条路:

方式用在哪
PluginServiceProvider#getPluginBean(appCode, type)(SPI,推荐)按需取一次 bean
XxxServiceLocator#proxy(Class)(返回每次调用都反查的代理)宿主里的长期消费方,装卸 / 升级都跟得上

定位器本身是平台段的薄包装(wujies-common-plugin-sdk 里二十来行一个类), 逻辑统一在 wujies-common-core 的 PluginServiceLocator。

绝对不要在宿主上下文里注册与插件实现同类型的 bean

哪怕是"转发代理"也不行。Spring 按类型注入走 beanNamesForTypeIncludingAncestors, 会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报 expected single matching bean but found 2。

宿主代码注入不了插件的 Mapper

Mapper bean 确实注册在宿主 bean factory 里,但那些接口的类型来自插件 —— 平台对 cn.wujies.<app>.* 的 Java 引用为 0,宿主代码编译期拿不到这些类型。 宿主想要插件的数据,走定位器调服务,或走事件;只有同为插件的消费方才能直接注入(§6)。

11. 一个应用长什么样:真实文件清单 ​

wujies-hello(最小范例,后端全部文件):

文件作用
app.json清单(编码 / 版本 / assets / 菜单树,含 type:"F" 按钮)
pom.xml5 个平台依赖 + 兄弟插件构件 + SnailJob;没有自己的契约模块
migrations/01-create-hello-tables.sql建表(同时是"彻底删除"时删哪些表的唯一依据)
migrations/02-create-hello-seed-data.sql示例数据
migrations/03-create-hello-website-config.sql站点配置(sys_config,config_category='hello')
controller/HelloController.java管理端接口:/hello/greet、/hello/item/list
controller/HelloDemoHostController.java演示点 1:调用宿主系统服务
controller/HelloDemoCrossPluginController.java演示点 2:跨插件调用(Mapper vs 定位器)
open/HelloDemoOpenController.java演示点 3:开放 /open/hello/**
task/HelloDemoScheduledTask.java演示点 4a:@Scheduled
task/HelloDemoSnailJobTask.java演示点 4b:SnailJob @JobExecutor
domain/HelloItem.java、mapper/HelloItemMapper.java、service/HelloService.java、service/impl/HelloServiceImpl.java一个实体的完整纵向切片

wujies-gallery(规模参考,src/main/java/cn/wujies/gallery/ 共 47 个 Java 文件, 另有 src/main/resources/mapper/gallery/ 下 5 个 Mapper XML):

目录文件数说明
controller/5管理端接口
open/3对外开放接口
listener/2消息消费者
service/12服务接口与实现
mapper/5Mapper 接口(与上面的 XML 一一对应)
domain/17实体 / BO / VO
constant/、fallback/1 + 2常量与定位器(fallback/ 是历史位置;新建应用的定位器放平台段,见 §6)

(数字按当前仓库统计,用来判断"一个真实应用有多大";不要把它当验收标准。)

12. 写完之后的验收 ​

检查项怎么查
插件被装载部署响应 hotLoaded: true;hotLoadNote 里写「插件 X 已加载:组件 N 个,接口 M 个」
Mapper 注册成功宿主日志:插件 X 的 Mapper 已注册:接口 N 个,XML M 个,语句 K 条
菜单没有重复建安装 / 升级前后 sys_menu 行数不变(菜单按 menu_key 幂等复用既有 menu_id)
接口真的通了管理端接口带 token 打一次;同类插件路由不带 token 应 401(除非你要的是 @SaIgnore)
宿主消费方通了走定位器的那个宿主接口调一次,拿到真实数据说明注入点都对;报「应用『xxx』未安装或未启用」则是定位器或加载有问题
任务真的跑了SnailJob 控制台看执行记录;本地调度看服务端日志

排查入口:故障排查、本地联调、 任务中心(安装 / 升级 / 卸载的每一步都在那里落库,比翻服务端日志快)。

相关阅读 ​

我们坚信,即使再复杂的技术,也可以用清晰、干练、易懂的文字描述清楚。如果你在阅读时有难以理解的章节,那一定是我们还没有优化好它 —— 欢迎反馈。