【硬核】Log4j2 与 Logback 当初的选型以及在当前云原生环境下的反思与展望

个人创作公约:本人声明创作的所有文章皆为自己原创,如果有参考任何文章的地方,会标注出来,如果有疏漏,欢迎大家批判。如果大家发现网上有抄袭本文章的,欢迎举报,并且积极向这个 github 仓库 提交 issue,谢谢支持~ 另外,本文为了避免抄袭,会在不影响阅读的情况下,在文章的随机位置放入对于抄袭和洗稿的人的"亲切"的问候。如果是正常读者看到,笔者在这里说声对不起,。如果被抄袭狗或者洗稿狗看到了,希望你能够好好反思,不要再抄袭了,谢谢。

1. 最初为何选择 Log4j2

最初,我在选型的时候,比较看重两点:

  1. 针对异步日志的兼容以及性能,还有监控
    1. 异步日志基本上是必须的,这样保证在日志量大的时候,不至于阻塞业务线程影响业务。
    2. 异步日志基本实现都是通过阻塞队列,要考虑阻塞队列满的时候的处理,是丢弃还是等待,丢弃会丢日志,等待会影响业务。
    3. 因此,需要对队列做好监控,在队列满的时候,可能需要赶紧扩容,并暂时将这个实例下线等待处理完。
  2. 是否容易设计出 error 级别以上的日志
    1. 我们的报警与日志强关联
    2. 目前报警途径有短信,有邮件
    3. 强报警走短信,弱的走邮件
    4. 一般的 error 如果不多,算是弱报警。特殊的一些,出了必须尽快处理,走强报警。

经过调研,Log4j2 在性能与新技术上的使用上,比 logback 领先,但是 logback 在兼容性上是做的更好的:

1.1. 异步日志与阻塞队列的实现对比

两者都用了阻塞队列实现,我们用下面的图片演示下各配置一个异步 Logger 以及一个 RollingFileAppender 的情况:

流程大概是:

  1. 日志事件的生成 :当应用程序代码调用日志记录方法(如 logger.debug(),logger.info()等)时,这些方法封装日志信息成一个日志事件对象。
  2. 日志事件的入队 :这个日志事件对象会被放入AsyncAppender维护的BlockingQueue中。这个过程通常很快,因为它只是将事件放入队列。
  3. 队列的消费AsyncAppender启动的工作线程会在后台运行,循环访问队列。当队列中存在事件时,有个单线程会将事件取出,并将其传递给实际的日志处理Appender,如FileAppenderConsoleAppender
  4. 日志的写入 :单线程会调用配置的Appender来实际写入日志信息到目标媒介(如文件、控制台等)。

这里主要的区别在于,Log4j2 使用了更为先进的 LMAX 的 RingBuffer 来实现 ArrayBlockingQueue。Log4j2 异步日志基于高性能数据结构 Disruptor,Disruptor 是一个环形 buffer,做了很多性能优化(具体原理可以参考我的另一系列:高并发数据结构disruptor )。logback 则是考虑兼容性以及简洁,使用了 JDK 自带的 ArrayBlockingQueue

1.2. 如何获取代码位置(虽然非常不建议在线上开启代码位置输出)

两者都通过获取当前堆栈的方式,获取代码位置。区别在于:

  1. logback 使用新建 Throwable 的方式获取的堆栈。
  2. log4j2 针对 Java 8 以及之前的环境,使用新建 Throwable 的方式,针对 Java 9 之后的环境,使用 StackWalker 的方式。

在这一点上,在 Java 9+ 的环境下,如果考虑开启获取代码位置,Log4j2 会非常有优势。因为通过 Throwable 的方式获取堆栈,不管用得着用不着,都会初始化堆栈上面的每一帧(在后文的分析中我们会看到这是很大很大的消耗)。而通过 StackWalker 的方式,仅会初始化你需要的堆栈帧。而针对日志获取输出日志位置的场景,我们不需要初始化所有栈帧,只需要前几个。

1.3. Log4j2 内置完善的监控,以及比 error 更高的级别

log4j2 内置完善的监控,通过 jmx mbean 暴露,比如就有很重要的 RingBuffer 的使用量这种。logback 并没有做什么监控。

log4j2 在 error 级别上还有 fatal 级别,logback 考虑了兼容性,日志级别和 slf4j 是一模一样的。

因此,考虑性能,技术先进,以及有比 error 更高的级别,我们选择了 Log4j2,抛弃了兼容性更好的 Logback

2. 针对 Log4j2 的调优

针对 Log4j2,我们也做了很多调优

2.1. 关闭输出代码位置

我们先来自己思考下如何实现:首先 Java 9 之前,获取当前线程(我们这里没有要获取其他线程的堆栈的情况,都是当前线程)的堆栈可以通过:

其中 Thread.currentThread().getStackTrace(); 的底层其实就是 new Exception().getStackTrace(); 所以其实本质是一样的。

Java 9 之后,添加了新的 StackWalker 接口,结合 Stream 接口来更优雅的读取堆栈,即:

我们先来看看 new Exception().getStackTrace(); 底层是如何获取堆栈的:

github.com/openjdk/jdk...

然后是 StackWalker,其核心底层源码是:

可以看出,核心都是填充堆栈详细信息,区别是一个直接填充所有的,一个会减少填充堆栈信息。填充堆栈信息,主要访问的其实就是 SymbolTable,StringTable 这些,因为我们要看到的是具体的类名方法名,而不是类的地址以及方法的地址,更不是类名的地址以及方法名的地址。那么很明显:通过 Exception 获取堆栈对于 Symbol Table 以及 String Table 的访问次数要比 StackWalker 的多,因为要填充的堆栈多

我们接下来测试下,模拟在不同堆栈深度下,获取代码执行会给原本的代码带来多少性能衰减:模拟两种方式获取调用打印日志方法的代码位置,与不获取代码位置会有多大性能差异

以下代码我参考的 Log4j2 官方代码的单元测试,首先是模拟某一调用深度的堆栈代码:

然后,编写测试代码,对比纯执行这个代码,以及加入获取堆栈的代码的性能差异有多大。

执行:查看结果:

从结果可以看出,获取代码执行位置,也就是获取堆栈,会造成比较大的性能损失 。同时,这个性能损失,和堆栈填充相关。填充的堆栈越多,损失越大。可以从 StackWalker 的性能优于通过异常获取堆栈,并且随着堆栈深度增加差距越来越明显看出来

这个性能衰减,从对于底层 JVM 源码的分析,其实可以看出来是因为对于 StringTable 以及 SymbolTable 的访问,我们来模拟下这个访问,其实底层对于 StringTable 的访问都是通过 String 的 intern 方法,即我们可以通过 String::intern 方法进行模拟测试,测试代码如下:

测试结果:

对比 StackWalkBenchmark.baselineStackWalkBenchmark.toString 的结果,我们看出 bh.consume(time); 本身没有什么性能损失。但是通过将他们与 StackWalkBenchmark.intern 以及 StackWalkBenchmark.intern3 的结果对比,发现这个性能衰减,也是很严重的,并且访问的越多,性能衰减越严重(类比前面获取堆栈)。

所以,对于微服务环境,尤其是响应式微服务环境,堆栈深度非常深,如果会输出大量的日志的话,这个日志是不能带有代码位置的,否则会造成严重的性能衰减。我们在线上,还是关闭了代码位置输出,而是在日志中主动添加体现位置的信息(例如类名和方法名):

2.2. RingBuffer 满了的策略以及增加对于 RingBuffer 的 metric 监控与暂时下线的机制

如果异步日志的阻塞队列满了,如何处理?一般有两种方式:

  1. 直接丢弃。
  2. 当前输出日志线程阻塞等待。

2.2.1. Log4j2 如何实现直接丢弃以及为什么我们不用

这涉及三个配置属性(可以通过 Spring Boot 属性(log4j-spring开头的),系统属性(类似于 System.setProperty("log4j2.AsyncQueueFullPolicy", "Discard"); 这样,或者位于 log4j2.system.properties 文件的 ),环境变量(LOG4J_开头),log.component.properties 四种方式配置,优先级从前面提到的四种从前到后优先级下降):

  • log4j2.AsyncQueueFullPolicy(对应环境变量:LOG4J_ASYNC_QUEUE_FULL_POLICY):默认是当前提交日志的线程等待,可以配置为 Discard

    • 如果配置了 log4j2.AsyncQueueFullPolicy=Discard:当 RingBuffer 满了的时候,小于等于 log4j2.discardThreshold (对应环境变量:LOG4J_DISCARD_THRESHOLD,默认为 INFO)配置的日志会被丢弃。高于的还是会当前提交日志的线程等待。
    • 如果没有配置 log4j2.AsyncQueueFullPolicy,或者日志级别高于 log4j2.discardThreshold: 根据 AsyncLogger.SynchronizeEnqueueWhenQueueFull(默认 true) 决定是直接都调用 disruptor 的 publish 抢着提交还是内部 syncrhonized 提交。不过,由于我们要考虑对于虚拟线程的兼容性,不能用 synchronized,所以要设置 AsyncLogger.SynchronizeEnqueueWhenQueueFull=false

虽然异步日志,无法完全避免丢失日志(一般是进程强制被杀的时候)。但是我们还是想尽量避免,所以我们不会考虑在 RingBuffer 满了的时候直接丢弃日志 。并且,直接丢弃日志,可能会导致,流量高峰一来,就丢了很多很多日志,并且出问题的时候一般是流量高峰,一般是最需要日志去定位问题的时候。

我想实现的是,在 RingBuffer 中日志量过多,或者剩余为 0 的时候,启动新实例,将当前实例暂时从注册中心下线,等 RibgBuffer 中的日志消费差不多,再上线。这就是下一节要讨论的

2.2.2. Log4j2 如何实现对于 RingBuffer 的监控

log4j2 disruptor 的 RingBuffer 既然是一个环形 Buffer,它的容量是有限的:

github.com/apache/logg...

github.com/apache/logg...

如果启用了 ThreadLocal 这种方式生成 LogEvent,每次不新生成的 LogEvent 用之前的,用 ThreadLocal 存储的,这样避免了创建新的 LogEvent。但是考虑下面这种情况:

arduino 复制代码
    logger.info("{}", someObj);

这样会造成强引用,导致如果线程没有新的日志,这个 someObj 一直不能回收。所以针对 Web 应用,log4j2 默认是不启用 ThreadLocal 的 方式生成 LogEvent

github.com/apache/logg...

由此,可以看出,我们的 RingBuffer 的大小为 256 kB 。我们需要对这个 RingBuffer 的使用量进行监控。首先,这个为啥不通过其他指标进行监控呢?比如硬盘 IO。这种是进程外部采集系统指标监控:现在服务都提倡上云,并实现云原生服务。对于云服务,存储日志很可能使用 NFS(Network File System),例如 AWS 的 EFS。这种 NFS 一动都可以动态的控制 IO 最大承载,但是服务的增长是很难预估完美的,并且高并发业务流量基本都是一瞬间到达,仅通过 IO 定时采集很难评估到真正的流量尖峰(例如 IO 定时采集是 5s 一次,但是在某一秒内突然到达很多流量,导致进程内大多线程阻塞,这之后采集 IO 看到 IO 压力貌似不大的样子)。并且,由于线程的阻塞,导致可能我们看到的 CPU 占用貌似也不高的样子。所以,外部定时采集指标,很难真正定位到日志流量问题。

然后我们考虑进程自己监控,暴露接口给外部监控定时检查,例如 K8s 的 pod 健康检查等等。在进程的日志写入压力过大的时候,新扩容一个实例;启动完成后,在注册中心将这个日志压力大的进程的状态设置为暂时下线(例如 Eureka 置为 OUT_OF_SERVICE,Nacos 置为 PAUSED),让流量转发到其他实例。待日志压力小之后,再修改状态为 UP,继续服务。

Log4j2 对于每一个 AsyncLogger 配置,都会创建一个独立的 RingBuffer,例如下面的 Log4j2 配置:

这个配置包含 4 个 AsyncLogger,对于每个 AsyncLogger 都会创建一个 RingBuffer。Log4j2 也考虑到了监控 AsyncLogger 这种情况,所以将 AsyncLogger 的监控暴露成为一个 MBean(JMX Managed Bean)。

相关源码如下:

github.com/apache/logg...

创建的 MBean 的类源码:github.com/apache/logg...

我们可以通过 JConsole 查看对应的 MBean:

image

其中 2f0e140b 为 LoggerContext 的 name。

我们的微服务项目中使用了 spring boot,并且集成了 prometheus。我们可以通过将 Log4j2 RingBuffer 大小作为指标暴露到 prometheus 中,通过如下代码:

增加这个代码之后,请求 /actuator/prometheus 之后,可以看到对应的返回:

这样,当这个值为 0 持续一段时间后(就代表 RingBuffer 满了,日志生成速度已经远大于消费写入 Appender 的速度了),我们就认为这个应用日志负载过高了。

2.2.3. 通过批量 flush 进一步提高吞吐量

在前面配置的基础上,我们还是遇到了一些超高热点的微服务的日志刷入导致 RingBuffer 满导致频繁下线的问题,考虑一些权衡后,我们可以配置 Appender 的 immediateFlush 为 false,这样就不会每次有日志到就会 flush 文件,而是达到 log4j.encoder.byteBufferSize 大小才会 flush,例如:

这里的原理对应源码:

github.com/apache/logg...

那么对于 Log4j2 Disruptor 异步日志来说,什么时候 LogEventEndOfBatch 呢?是在消费到的 index 等于生产发布到的最大 index 的时候,这也是比较符合性能设计考虑,即在没有消费完的时候,尽可能地不 flush,消费完当前所有的时候再去 flush(这里还没有考虑 log4j.encoder.byteBufferSize 的 buffer 限制,默认 8KB):

https://github.com/LMAX-Exchange/disruptor/blob/master/src/main/java/com/lmax/disruptor/BatchEventProcessor.java

我们知道,现在一般文件写入都是有 user space 的 buffer 的,Java 各种框架也不例外,这里 Log4j2 也是有的:

首先是 buffer 大小的配置: github.com/apache/logg...

然后是在写入占满 buffer 后才会 flush 的逻辑:

github.com/apache/logg...

2.2.4. 配置 Disruptor 的等待策略为 SLEEP,但是最好能将其中的 Thread.yield 修改为 Thread.onSpinWait (这个修改仅针对 x86 机器部署)

Disruptor 的消费者做的事情其实就是不断检查是否有消息到来,其实就是某个状态位是否就绪,就绪后读取消息进行消费。至于如何不断检查,这个就是等待策略。Disruptor 中有很多等待策略,熟悉多处理器编程的对于等待策略肯定不会陌生,在这里可以简单理解为当任务没有到来时,线程应该如何等待并且让出 CPU 资源才能在任务到来时尽量快的开始工作 。在 Log4j2 中,异步日志基于 Disruptor,同时使用 AsyncLoggerConfig.WaitStrategy 这个环境变量对于 Disruptor 的等待策略进行配置,目前最新版本的 Log4j2 中可以配置:

我们这里使用其中策略最为均衡的 SleepingWaitStrategy。在当前的大多数应用中,线程的个数都远大于 CPU 的个数 ,甚至是 RUNNABLE 的线程个数都远大于 CPU 个数,使用基于 Wait 的 BusySpinWaitStrategy 会导致业务闲时突然来业务高峰的时候,日志消费线程唤醒的不够及时(CPU 一直被大量的 RUNNABLE 业务线程抢占)。如果使用比较激进的 BusySpinWaitStrategy(一直调用 Thread.onSpinWait())或者 YieldingWaitStrategy(先 SPIN 之后一直调用 Thread.yield()),则闲时也会有较高的 CPU 占用。我们期望的是一种递进的等待策略,例如:

  1. 在一定次数内,不断 SPIN,应对日志量特别多的时候,减少线程切换消耗。
  2. 在超过一定次数之后,开始不断的调用 Thread.onSpinWait() 或者 Thread.yield(),使当前线程让出 CPU 资源,应对间断性的日志高峰。
  3. 在第二步达到一定次数后,使用 Wait,或者 Thread.sleep() 这样的函数阻塞当前线程,应对日志低峰的时候,减少 CPU 消耗。

SleepingWaitStrategy 就是这样一个策略,第二步采用的是 Thread.yield(),第三步采用的是 Thread.sleep()。同时,我们修改其中的 Thread.yield()Thread.onSpinWait()原因是 :我们部署到的环境是 x86 的机器,在 x86 的环境下 Thread.onSpinWait() 在被调用一定次数后,C1 编译器就会将其替换成使用 PAUSE 这个 x86 指令实现。参考 JVM 源码: x86.ad

我们知道,CPU 并不会总直接操作内存,而是以缓存行读取后,缓存在 CPU 高速缓存上。但是对于这种不断检查检查某个状态位是否就绪的代码,不断读取 CPU 高速缓存,会在当前 CPU 从总线收到这个 CPU 高速缓存已经失效之前,都认为这个状态为没有变化。在业务忙时,总线可能非常繁忙,导致 SleepingWaitStrategy 的第二步一直检查不到状态位的更新导致进入第三步。

PAUSE 指令(参考:www.felixcloutier.com/x86/pause )是针对这种等待策略实现而产生的一个特殊指令,它会告诉处理器所执行的代码序列是一个不断检查某个状态位是否就绪的代码(即> spin-wait loop),这样的话,然后 CPU 分支预测就会据这个提示而避开内存序列冲突,CPU 就不会将这块读取的内存进行缓存,也就是说对 spin-wait loop 不做缓存,不做指令 重新排序等动作。从而提高 spin-wait loop 的执行效率。

这个指令使得针对 spin-wait loop 这种场景,Thread.onSpinWait()的效率要比 Thread.yield() 的效率要高。所以,我们修改 SleepingWaitStrategy 的 Thread.yield()Thread.onSpinWait()

3. 发现 JVM 日志也可能卡住以及解决办法

同时,我们在优化 Log4j2 的性能的时候,也遇到了因为输出 JVM 日志导致 JVM 的 GC 卡住的问题,参考这篇文章:zhuanlan.zhihu.com/p/530190679

解决方案是:通过 -Xlog:async 启用 JVM 异步日志,通过 -XX:AsyncLogBufferSize= 指定异步日志缓冲大小,这个大小默认是 2097152 即 2MB。异步日志的原理是:

4. Log4j2 配置示例总结

我们使用的示例配置文件:

log4j2.xml 配置:

log.component.properties 配置:

总结一下,我们的 Log4j2 配置的优化点:

  1. 关闭输出代码位置(includeLocation="false"
  2. RingBuffer 满了的策略,以及增加对于 RingBuffer 的 metric 监控与暂时下线的机制
  3. 通过批量 flush 进一步提高吞吐量(immediateFlush="false"
  4. 配置 Disruptor 的等待策略为 SLEEP,但是最好能将其中的 Thread.yield 修改为 Thread.onSpinWait (这个修改仅针对 x86 机器部署)

5. Log4j2 对于 GraalVM 不支持,需要等 Log4j3

做了这些优化后,日志终于不再是瓶颈了,就这样过了一年多,我们发现我们有很多场景需要 GraalVM 原生镜像,但是 Log4j2 由于更考虑性能与新技术的使用,兼容性做的比 Logback 差。因此,对于 GraalVM 的支持,也是遇到了很多困难,并且现在也没有解决,可以参考这个 issue:issues.apache.org/jira/browse...

里面提到了:

  1. Log4j2 对于兼容 GraalVM 热情不高,即使考虑兼容,也是在 log4j3 里面兼容了,log4j3 的预计发布日期是 2024.3.31(参考:github.com/apache/logg...
  2. GraalVM 里面没有 SecurityManager,其实 JVM 也会慢慢废弃这个,目前 Log4j2 中关于这块的依赖需要移除
  3. Log4j3 里面会重构一些有关反射的,利于兼容 GraalVM。但是目前还没有计划

目前,截止 2024-02,笔者对于 log4j 兼容 GraalVM 持悲观态度

6. GraalVM Native Image 带来的实际好处

虽然 GraalVM Polyglot 多语言支持有很多很多限制,但是在 Java 编译原生镜像这个方面,确实带来了很多好处,对于我们项目来说,最实际的如下几点:

  1. 测试环境成本的降低:为了最大化

  2. 针对一次性大数据处理任务,定时任务报表:

    1. 如果放在常驻的微服务中,那么会出现资源浪费或者资源耗尽影响正常功能的现象:

      1. 如果按照任务所需大小分配 CPU 以及内存,那么平常用不到
      2. 如果不按照任务所需,则会吃满 CPU 或者内存,影响其他业务
    2. 需要使用 AWS lambda 或者 k8s CronJob 这样的一次性进程启动服务来实现。但是这些都对于启动时间有很严格的限制。传统 Java Spring Boot 应用启动慢。

  3. GraalVM Native Image 的尖峰性能,确实不如 JVM JIT 优化的。虽然 GraalVM 社区一直吹捧已经很接近了,但是实际在使用中,同内存 CPU 还是有 10% 左右的差距。但是,针对那种不好预估业务尖峰何时到来,必须依靠动态扩容,并且对于稳定性要求又很高的,需要使用这种启动快,不需要预热的部署。比如 kol 引流,广告微服务,必须牺牲尖峰性能来换取稳定性与扩容后快速达到正常服务状态(JVM JIT 预热可能会吃掉所有 CPU 导致响应很慢影响体验)。

7. 转换为 Logback 需要兼容现有 Log4j2 调优的点

处于想要使用 GraalVM 原生镜像的目的,我们也有考虑将一部分微服务,从 Log4j2 转换为 Logback,需要考虑我们前面优化的 Log4j2 的点,哪些需要迁移到 Logback:

7.1. 关闭输出代码位置

这个必须迁移,因为 Logback 获取代码位置的方式远比 Log4j2 的要更耗性能,可以参考源码:

github.com/qos-ch/logb...

github.com/qos-ch/logb...

我们可以通过如下配置方式关闭:

logback.xml:

bash 复制代码
    <includeCallerData>false</includeCallerData>

6.2. ArrayBlockingQueue 满了的策略以及增加对于 ArrayBlockingQueue 的 metric 监控与暂时下线的机制

logback 并没有暴露对于 ArrayBlockingQueue 的监控,这里需要自己实现。我们可以通过如下代码:

6.3. 通过批量 flush 进一步提高吞吐量

logback 也有类似的配置,参考源码:

github.com/qos-ch/logb...

而如果不是 immediateFlush,那么日志会先写入到内存中,然后 buffer 满的时候才会刷盘,这样会提高性能。

github.com/qos-ch/logb...

7. 与前面优化过的 log4j2 配置基本等价的 logback 配置示例

logback.xml 配置:

8. 一些反思

项目与技术都在不断发展,现在也终于明白一些为何国外那种持续时间久的项目,主要考虑兼容性而不是性能了。也理解了为啥 Spring 社区使用的默认的日志框架是 Logback,而不是 Log4j2。Log4j2 为了性能,放弃了很多兼容性,而 Logback 为了兼容性,放弃了一些性能。这也是为啥 Log4j3 会在 2024 年才发布,因为要兼顾性能与兼容性,这个工作量是非常大的。Logback 看似迭代缓慢,但是其实是在考虑兼容性。

其实类比到其他框架,spring-data-jpa 那一套,虽然学习门槛高,不能像 MyBatis 一样自由控制 SQL,同时还有很多理解不到位误用导致性能瓶颈的坑需要注意。但是,不论以后换什么数据库,只要是 JPA 规范的,都可以无缝切换,这就是兼容性的好处。而项目发展越久,在现在云原生迸发的时代,兼容性的重要性会越来越大。并且,JPA 的规范,也会使你的项目业务代码更加干净,不会因为 SQL 而导致业务代码变得复杂。

微信搜索"hashcon"关注公众号,加作者微信 我会经常发一些很好的各种框架的官方社区的新闻视频资料并加上个人翻译字幕到如下地址(也包括上面的公众号),欢迎关注:

相关推荐
ya888g12 分钟前
GESP C++四级样题卷
java·c++·算法
【D'accumulation】23 分钟前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
小叶学C++28 分钟前
【C++】类与对象(下)
java·开发语言·c++
2401_8543910832 分钟前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss41 分钟前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
wxin_VXbishe41 分钟前
springboot合肥师范学院实习实训管理系统-计算机毕业设计源码31290
java·spring boot·python·spring·servlet·django·php
Cikiss42 分钟前
微服务实战——平台属性
java·数据库·后端·微服务
无敌の星仔1 小时前
一个月学会Java 第2天 认识类与对象
java·开发语言
OEC小胖胖1 小时前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617621 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端