Apache Tomcat 详解

Apache Tomcat 详解

前言

早年间,精通CRUD的小菜同学在Tomcat上通过继承HttpServlet进行CRUD

后来,有了Spring MVC框架的DispatcherServlet,让小菜更容易的进行CRUD

到现在,Spring Boot框架内嵌Web服务器,让小菜更轻松、更便捷的专注CRUD

小菜保持专一的原则,一心只关注CRUD,从未对服务器、框架有过"非分之想"

突然有一天,小菜不知道改动了哪里,程序跑不起来了

小菜心想:程序跑不了,那我岂不是得跑了?不行,不行,大环境这么恶劣,我可不能跑啊

于是,小菜开始查看各种中间件的运行原理,抽丝剥茧一层一层解析各种各样的中间件...

一、简介

Tomcat 服务器是一个开源的轻量级Web应用服务器 ,在中小型系统和并发量小的场合下被普遍使用,是开发和调试Servlet、JSP 程序的首选。

整个图描述了一个 典型的动态请求处理周期

  1. 发起请求:
    • 浏览器 向服务器发起一个 HTTP 请求(比如,访问一个网页链接或提交一个表单)。
  2. 接收请求:
    • 这个请求首先到达被标注为 "Web服务器" 的组件。在标准的 Tomcat 架构中,这个角色实际上是 Tomcat 的连接器(Connector)
    • 它的职责是:监听网络端口(如 8080),接收所有来自客户端的 HTTP 请求,并建立网络连接。
  3. 处理业务逻辑:
    • 连接器将请求转发给被标注为 "server、jsp容器" 的组件。这个组件实际上就是 Tomcat 的核心------Servlet 容器(Container),它包含了 Servlet 和 JSP 的执行环境。
    • 它的职责是
      • 根据 URL 找到对应的 ServletJSP 文件。
      • 执行其中的 Java 代码(业务逻辑)。
      • 在需要时,图中的 "操作数据库" 步骤发生在这里。即你的 Servlet 代码会通过 JDBC 等技术与 数据库 进行交互,查询、更新或删除数据。
  4. 生成响应:
    • Servlet 或 JSP 处理完业务逻辑、获取到数据后,会生成一个动态的响应(通常是 HTML 页面,也可以是 JSON 数据等)。
  5. 返回响应:
    • 这个动态生成的响应,沿着原来的路径返回:Servlet 容器 -> 连接器 -> 浏览器
    • 浏览器接收到响应后,将其渲染成网页展示给用户。

二、详解

作为Web服务器 ,那必须要先处理网络请求,处理完网络通信,再进行业务处理

在这个过程中,秉承着高内聚、低耦合的设计思想,可以划分为两个组件处理这些事情

  1. Connector(连接器):负责处理网络通信
  2. Container(容器):负责处理业务 比如servlet容器

一个Container容器和一个或多个Connector组合在一起,加上其他一些支持的组件共同组成一个Service服务,有了Service服务便可以对外提供能力了,但是Service服务的生存需要一个环境,这个环境便是Server,Server组件为Service服务的正常使用提供了生存环境,Server组件可以同时管理一个或多个Service服务。

1、连接器(Connector)

连接器处理网络通信又可以分为多个步骤:处理通信(获取socket)、解析协议、封装请求/响应

Connector组件是Tomcat中的两个核心组件之一,他的主要任务是负责接收浏览器发过来的TCP连接请求,创建一个Request和Response对象分别用于和请求端交换数据。然后会产生一个线程来处理这个请求并把产生的Request和Response对象传给处理这个请求的线程,处理这个请求的线程就是Container组件要做的事了。

在Tomcat中这三个工作分别交给三个组件进行处理:

  1. EndPoint:处理通信、获取socket
  2. Processor:解析本次网络请求的协议
  3. Adapter:封装解析的请求/响应交给容器
1.1 AbstractEndPoint

EndPoint从名称上看就知道是做点到点的通信,传输层与应用层间使用Socket处理网络通信

Tomcat 9中实际没有EndPoint的接口,只有抽象类,具体实现只有两种:

  1. NioEndPoint:基于多路复用模型的NIO
  2. Nio2EndPoint:基于AIO的NIO2

EndPoint能够使用不同的IO模型来实现网络通信获取Socket

EndPoint实际上还有一种APR的实现(AprEndpoint):在早期JDK NIO性能并不理想,使用编写的本地库来提升性能,后来在Tomcat 10被舍弃

1.2 Processor

Processor组件的接口是Processor 用于解析协议

从AbstractProcessor的实现类中可以看到,它可以解析HTTP、AJP协议(AJP协议更高效,比如Nginx反向代理使用AJP会更快)

UpgradeProcessorBase则是用于协议升级,比如实现WebSocket

Processor能够解析协议,将流解析为Tomcat中封装的请求与响应

1.3 ProtocolHandler

Tomcat在设计上将动态变化的EndPoint、Processor组合成ProtocolHandler:负责网络通信获取Socket并将流解析为请求/响应

EndPoint可以使用NIO、NIO2的方式进行网络通信,而Processor能够解析HTTP、AJP协议

在ProtocolHandler中设计上却又使用了继承的方式,当动态变化的值太多时,会导致继承类爆炸(好在这里只有 2*2=4)

ProtocolHandler只是将两个能够动态改变的子组件进行组合

1.4 Adapter

Adapter 从名称就知道它是适配器模式

Processor解析流封装的请求/响应是Tomcat中定义的,Adapter将请求/响应转化为Servlet的请求/响应,方便后续容器进行处理

Adapter适配器转换请求/响应是固定的,不会随着IO模型、协议改变,只有一个实现类

1.5 线程池

在多路复用IO模型(NIO)中,当线程监听到某个通道上数据就绪(发生事件),就可以进行处理

由于可能多个通道同时发生事件,此时肯定不能让监听的线程同步进行处理的,否则会阻塞后续的流程

因此会使用线程池对工作线程进行管理,监听到通道上数据就绪后,就交给工作线程执行后续任务

实际上EndPoint不仅存在线程池还涉及其他组件

这里的线程池是Tomcat自己实现的,并不是JUC下实现的线程池

思考:为什么Tomcat总是自己实现组件呢?为什么不使用已有的轮子呢?网络通信也是自己实现,为啥不用Netty呢?

Tomcat不仅仅是一个"网络服务器",而是一个完整的"Servlet容器生态系统"。网络通信只是这个生态系统中的一个组件,需要与其他组件(会话管理、JSP编译、资源管理等)深度集成,这种深度集成使得使用通用的网络框架反而会增加复杂度。

对于新建的纯网络服务,选择Netty可能是更好的选择;但对于需要完整Servlet生态系统支持的Web应用,Tomcat的自研架构仍然是合理的选择。

1.5 多连接器

连接器中不变的是Adapter适配器,变动的是IO模型、协议、端口等

那么Tomcat是否支持多个不同的连接器由一个容器处理呢?

答案是支持的,Tomcat为了方便扩展设计成支持多个不同的连接器绑定同一个容器(Spring Boot中用默认HTTP、NIO、8080的连接器)

默认连接器使用Http11NioProtocol监听8080端口(HTTP、NIO、8080)

在默认的基础上增加一个连接器,使用AjpNio2Protocol监听6666(AJP、NIO2、6666)

运行时会根据端口、协议找到连接器进行处理

2、容器(Container)

如果让我们来设计容器,很多人的第一反映肯定就是设计一个Servlet容器

当连接器处理完通信,封装好请求,直接交给这个Servlet容器进行处理

但是Tomcat并没有只单独设计一个Servlet容器

为了能够灵活扩展,Tomcat设计多层父子容器:Engine、Host、Context、Wrapper

  1. Wrapper代表Servlet,为最底层的容器,真正处理业务,不能再有子容器
  2. Context代表Web应用,能够包含多个Wrapper,即一个Web应用可以包含多个Servlet
  3. Host代表域名,即虚拟站点,每个Host允许有多个Context
  4. Engine代表引擎,最顶层容器,有且只有一个,允许有多个Host

Container是容器的父接口,该容器的设计用的是典型的责任链的设计模式,它由四个自容器组件构成,分别是Engine、Host、Context、Wrapper。这四个组件是负责关系,存在包含关系。通常一个Servlet class对应一个Wrapper,如果有多个Servlet定义多个Wrapper,如果有多个Wrapper就要定义一个更高的Container,如Context。

Context 还可以定义在父容器 Host 中,Host 不是必须的,但是要运行 war 程序,就必须要 Host,因为 war 中必有 web.xml 文件,这个文件的解析就需要 Host 了,如果要有多个 Host 就要定义一个 top 容器 Engine 了。而 Engine 没有父容器了,一个 Engine 代表一个完整的 Servlet 引擎。

这些容器接口都实现Container容器接口,其中都有对应的标准实现StandardXX,标准实现一般都继承抽象父类ContainerBase

一般只在标准实现上进行扩展,比如Spring Boot内嵌Tomcat:TomcatEmbeddedContext继承StrandardContext

举个HTTP请求的案例:

http://developer.zzz.com:8080/zzz/add

首先请求会经过连接器进行处理,连接器处理完将请求交给顶级容器Engine

假设配置两个Host:test.zzz.comdeveloper.zzz.com,由于我们请求的是developer.zzz.com则会被路由到对应Host

假设配置多个Context,会根据请求的前缀/zzz找到对应Context,wrapper同理

2.1 Mapper

在多级容器中根据请求路由到下级容器时,实际上是根据Mapper组件进行路由的

Mapper映射器会将请求进行解析,将HTTP请求映射到对应的servlet容器上

Mapper通过map方法解析映射并将结果封装起来,后续在多级容器中路由就能快速找到下一级容器

实际上Spring Boot中内嵌的Tomcat默认下每层容器都只有一个

在application.properties中设置

properties 复制代码
server.servlet.context-path=/zzz

访问 http://localhost:8080/zzz/test

一般现在微服务架构下的部署都是单节点单应用,因此Host一般都是localhost

而Context则是配置的contextPath:/zzz,其实现类是Spring Boot继承StandardContext的TomcatEmbeddedContext

而Wrapper则是MVC框架中实现的DispatchServlet,最后根据解析出的路径/test,再去DispatchServlet中寻找

2.2 PipeLine-Valve

Container处理请求是使用Pipeline-Value管道来处理的!

Pipeline-Value是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理,每个处理者负责做自己相应的处理,处理完之后将处理后的请求返回,再让下一个处理着继续处理。

为了方便扩展,在多级容器的调用链路中每个容器都使用职责链模式

Pipeline接口为职责链中的管道,Valve接口为管道中负责处理的节点

Pipeline管道分为First首节点和Basic基础节点,基础节点用于调用下一层容器,处于当前容器职责链的末尾,最后执行

也就是每层容器中职责链的调用顺序从First开始Basic结束

每个容器的Valve标准实现都是用作Basic基础节点的,它们最终会去调用下一层容器职责链(StandardEngine/Host/Context/WrapperValve)

Pipeline-Value使用的责任链模式和普通的责任链模式有些不同!区别主要有以下两点:

(1)每个Pipeline都有特定的Value,而且是在管道的最后一个执行,这个Value叫做BaseValue,BaseValue是不可删除的;

(2)在上层容器的管道的BaseValue中会调用下层容器的管道。

我们知道Container包含四个子容器,而这四个子容器对应的BaseValue分别在:StandardEngineValue、StandardHostValue、StandardContextValue、StandardWrapperValue。

2.3 FilterChain

作为最底层容器Wrapper的Valve标准实现,会将Servlet的过滤器和Servlet组装成过滤器链FilterChain,其在Servlet末尾执行。

(1)Connector在接收到请求后会首先调用最顶层容器的Pipeline来处理,这里的最顶层容器的Pipeline就是EnginePipeline(Engine的管道);

(2)在Engine的管道中依次会执行EngineValue1、EngineValue2等等,最后会执行StandardEngineValue,在StandardEngineValue中会调用Host管道,然后再依次执行Host的HostValue1、HostValue2等,最后在执行StandardHostValue,然后再依次调用Context的管道和Wrapper的管道,最后执行到StandardWrapperValue。

(3)当执行到StandardWrapperValue的时候,会在StandardWrapperValue中创建FilterChain,并调用其doFilter方法来处理请求,这个FilterChain包含着我们配置的与请求相匹配的Filter和Servlet,其doFilter方法会依次调用所有的Filter的doFilter方法和Servlet的service方法,这样请求就得到了处理!

(4)当所有的Pipeline-Value都执行完之后,并且处理完了具体的请求,这个时候就可以将返回的结果交给Connector了,Connector在通过Socket的方式将结果返回给客户端。

2.4 其他组件

在容器运行时还包含其他组件,如提供类加载的加载器Loader、管理session的管理器Manager...

Loader

Tomcat还提供Loader加载器,每个Context容器会关联一个Loader,用其对子组件进行类加载

同时后台会启动定时任务,判断Class文件是否改变,如果Class文件发生改变,则对其重新进行类加载,以此来实现热加载

(后续文章再对其进行说明)

Manager

由于HTTP协议是无状态的,因此可以使用cookie、session的方式在Web服务器维护状态

Tomcat提供Manager管理器与Context容器进行关联,对session进行管理(标准实现),在调用流程中维护session

3、Service、Server

前面说到一个或多个连接器共享同一个容器来对请求进行处理

Tomcat将连接器与容器组合成Service,以此来对外提供服务(相当于多包装一层)

作用:Service组件在Tomcat中的主要作用是管理一组关联的Connector和Engine组件。它负责接收和处理客户端请求,以及将这些请求传递给Engine组件进行进一步的处理。简单来说,Service组件是Tomcat中对外提供服务的核心组件。

Service主要用于关联一个Engine和与此Engine相关的Connector,每个Connector通过一个特定的port和协议接收请求并将其转发至关联的Engine进行处理。

Tomcat为了灵活设计,允许多个Service提供服务,使用Server管理Service(又多包装一层)

server作用:掌控全局,管理各个部分之间的工作

  1. 全局管理:Server组件是整个Tomcat实例的顶级容器,负责管理和协调所有Service组件(由上面的图我们可以看出server位于tomcat的最外围,包含着service、connector、engine、host等一系列容器)。它提供了服务器级别的配置,包括定义全局服务器配置、管理服务生命周期(tomcat从启动到停止)等。
  2. 监听关闭命令:Server组件会监听某个特定端口(默认是8005),以接收SHUTDOWN命令来关闭Tomcat服务器。当需要关闭服务器时,可以通过向该端口发送SHUTDOWN命令来实现。
  3. 提供监听器机制:Server组件提供了监听器机制,用于在Tomcat整个生命周期中对不同事件进行处理。这些监听器可以执行诸如初始化APR库、初始化Jasper组件、防止JRE内存泄露等任务。

总结

(1)Tomcat中只有一个Server,一个Server可以有多个Service,一个Service可以有多个Connector和一个Container;

(2) Server掌管着整个Tomcat的生死大权;

(4)Service 是对外提供服务的;

(5)Connector用于接受请求并将请求封装成Request和Response来具体处理;

(6)Container用于封装和管理Servlet,以及具体处理request请求;

4、Lifecycle

Tomcat中这么多组件,如何设计才能方便管理呢?

一般组件是要有生命周期的,比如在初始化(启动前)、启动时、结束前都需要做一些工作

做这些工作时(比如初始化),有的组件需要依赖别的组件,比如service肯定要依赖connector、container

而实现初始化最简单的办法就是从内到外依次进行初始化,但如果这样实现,后续组件多并且要扩展会导致逻辑乱,万一漏了个组件但又成功启动会导致错误难以排查

Tomcat使用Lifecycle接口来统一的管理组件的生命周期,提供init、start、stop、destroy等方法管理组件的初始化、启动、停止、卸载等生命周期

Server、Service的包装设计也是为了方便管理内部组件

在组件中再使用组合模式,启动父组件时,由父组件来启动子组件

比如调用父组件Server的init、start内部会去调用子组件Service的相同生命周期方法

StandardServer.initInternal()

java 复制代码
protected void initInternal() throws LifecycleException {
    // ...其他代码略
    // Initialize our defined Services
    for (Service service : services) {
   
        service.init();
    }
}

在前面已经见到过太多组件有自己的抽象父类了,Lifecycle也不例外

这样设计能够将固定的和变动的进行分离,固定的流程放在抽象父类中模板实现,变动的使用子类实现去进行扩展

比如LifecycleBase中实现Lifecycle接口init的模板骨架

Java 复制代码
@Override
public final synchronized void init() throws LifecycleException {
   
    //如果当前不是NEW状态抛出异常
    if (!state.equals(LifecycleState.NEW)) {
   
        invalidTransition(Lifecycle.BEFORE_INIT_EVENT);
    }

    try {
   
        //设置状态为INITIALIZING 初始化中
        setStateInternal(LifecycleState.INITIALIZING, null, false);
        //开始初始化
        initInternal();
        //设置状态为INITIALIZED 初始化结束
        setStateInternal(LifecycleState.INITIALIZED, null, false);
    } catch (Throwable t) {
   
        handleSubClassException(t, "lifecycleBase.initFail", toString());
    }
}

为了方便扩展(想在组件初始化前后做一些事情)Tomcat在生命周期中使用观察者模式,定义状态,当状态改变时即为事件发生,触发组件的监听器

Java 复制代码
protected void fireLifecycleEvent(String type, Object data) {
   
    //构建事件
    LifecycleEvent event = new LifecycleEvent(this, type, data);
    //遍历监听器处理事件
    for (LifecycleListener listener : lifecycleListeners) {
   
        listener.lifecycleEvent(event);
    }
}

组合优于继承、固定流程抽象模板骨架实现 像这种组件的设计都是Effective Java中说到过的原则

5、启动与停止

Tomcat服务器将启动/停止的功能单独抽离成新的组件

在原生Tomcat中使用Bootstrap引导类启动/停止Tomcat服务器

它会通过反射调用Catalina中的启动/停止方法,最终去调用Server的启动/停止

java 复制代码
public void start() throws Exception {
   
    if (catalinaDaemon == null) {
   
        init();
    }

    //catalinaDaemon 就是Catalina对象
    Method method = catalinaDaemon.getClass().getMethod("start", (Class [])null);
    method.invoke(catalinaDaemon, (Object [])null);
}

public void stop() throws Exception {
   
    Method method = catalinaDaemon.getClass().getMethod("stop", (Class []) null);
    method.invoke(catalinaDaemon, (Object []) null);
}

Catalina中提供关闭钩子,当程序异常关闭时执行关闭钩子

Java 复制代码
Runtime.getRuntime().addShutdownHook(shutdownHook);

当程序异常关闭时,会去用线程执行关闭钩子,停止服务器

Java 复制代码
protected class CatalinaShutdownHook extends Thread {
     
    @Override
    public void run() {
   
        try {
   
            if (getServer() != null) {
   
                Catalina.this.stop();
            }
        } catch (Throwable ex) {
   
            ExceptionUtils.handleThrowable(ex);
            log.error(sm.getString("catalina.shutdownHookFail"), ex);
        } finally {
   
            // If JULI is used, shut JULI down *after* the server shuts down
            // so log messages aren't lost
            LogManager logManager = LogManager.getLogManager();
            if (logManager instanceof ClassLoaderLogManager) {
   
                ((ClassLoaderLogManager) logManager).shutdown();
            }
        }
    }
}

而在Spring Boot中内嵌的Tomcat是通过Tomcat类进行启动/停止的

在Spring容器初始化Bean的流程中,会通过工厂来创建Web服务器,如果使用的是Tomcat则会通过org.apache.catalina.startup.Tomcat进行启动

6、请求流程源码分析

为了方便理解,通过源码梳理一条大致的主流程

启动和连接器EndPoint处理网络通信的源码留到后续文章分析,这里从监听到事件交给线程池处理开始(processor前)

  1. EndPoint 交给ProtocolHandler处理 getHandler().process(socketWrapper, event)
  2. ProtocolHandler调用Processor进行解析 processor.process(wrapper, status)
  3. Processor解析完请求,调用适配器Adapter进行封装 getAdapter().service(request, response)
  4. 适配器Adapter封装完请求/响应(会使用加载器解析请求映射),最后从Engine的职责链First开始调用connector.getService().getContainer().getPipeline().getFirst().invoke(request, response)
  5. Engine职责链调用完(当前为Basic,最后一个),从映射中获取Host继续职责链调用 host.getPipeline().getFirst().invoke(request, response)
  6. Host职责链调用完,从映射中获取Context继续职责链调用 context.getPipeline().getFirst().invoke(request, response)
  7. Context职责链调用完,从映射中获取Wrapper继续职责链调用 wrapper.getPipeline().getFirst().invoke(request, response)
  8. Wrapper职责链调用完,加载servlet并封装FilterChain继续调用过滤器链 filterChain.doFilter(request.getRequest(),response.getResponse())
  9. 调用完过滤器链,调用servlet的service servlet.service(request, response)


7、总结

本篇文章以自顶向下的形式描述Tomcat中部分核心组件以及运行流程,后续的文章将逐步从源码解析各核心组件,彻底剖析Tomcat~

连接器用于处理网络通信,其将IO模型、协议等动态改变的部分交给ProtocolHandler组件进行处理,固定不变的交给Adapter处理

ProtocolHandler中EndPoint负责监听通道,当通道数据就绪发生事件时,将事件封装好交给线程池处理

线程池中的线程开始处理,会使用ProtocolHandler中的Processor进行请求解析,将网络流解析为Tomcat封装的请求,然后再使用Adapter将Tomcat的请求/响应进行封装,能够得到Servlet中定义的请求/响应,接着调用容器进行处理

容器分为Engine、Host、Context、Wrapper的多级父子容器,其每层关系为一对多,调用链路使用职责链模式,Pipeline中使用Valve进行处理,每层职责链从First开始到Basic结束,Basic通常是每层的标准容器实现,用于调用下层容器

调用完Wrapper容器后,其标准实现会将servlet与过滤器组合为过滤器链进行调用,先调用过滤器最后再调用servlet

在容器中还有很多其他组件,如负责类加载器的加载器Loader、负责管理session的管理器Manager,负责多级容器间路由的映射器Mapper...

为了方便管理与扩展,允许多个连接器绑定同个容器,并将连接器与容器组合为Service提供服务,整个Tomcat为一个Server服务器,允许存在多个Service提供服务

组件间使用组合模式进行管理,实现生命周期接口,在初始化/启动/停止/卸载时,通过调用父组件的生命周期接口去触发子组件的生命周期方法

同时为了方便扩展还提供生命周期的监听器,当生命周期状态发生改变时可以进行扩展(观察者模式)

在原生的Tomcat中使用Bootstrap作为启动类,调用Catalina进行启动/停止,而在Spring Boot中内嵌服务器会使用封装的Tomcat进行启动/停止

在Tomcat的设计中,为了方便扩展使用职责链、观察者、模板等设计模式,多层容器、Service等冗余架构

容器后,其标准实现会将servlet与过滤器组合为过滤器链进行调用,先调用过滤器最后再调用servlet**

在容器中还有很多其他组件,如负责类加载器的加载器Loader、负责管理session的管理器Manager,负责多级容器间路由的映射器Mapper...

为了方便管理与扩展,允许多个连接器绑定同个容器,并将连接器与容器组合为Service提供服务,整个Tomcat为一个Server服务器,允许存在多个Service提供服务

组件间使用组合模式进行管理,实现生命周期接口,在初始化/启动/停止/卸载时,通过调用父组件的生命周期接口去触发子组件的生命周期方法

同时为了方便扩展还提供生命周期的监听器,当生命周期状态发生改变时可以进行扩展(观察者模式)

在原生的Tomcat中使用Bootstrap作为启动类,调用Catalina进行启动/停止,而在Spring Boot中内嵌服务器会使用封装的Tomcat进行启动/停止

在Tomcat的设计中,为了方便扩展使用职责链、观察者、模板等设计模式,多层容器、Service等冗余架构

现在微服务架构基本都是单应用部署,其中允许多实例的组件Service、Host、Context、Wrapper等一般都只有一个

相关推荐
SXJR5 小时前
Spring前置准备(八)——ConfigurableApplicationContext和DefaultListableBeanFactory的区别
java·后端·spring
IccBoY6 小时前
Java采用easyexcel组件进行excel表格单元格的自动合并
java·开发语言·excel
Hello.Reader6 小时前
Flink 广播状态(Broadcast State)实战从原理到落地
java·大数据·flink
泽虞6 小时前
《Qt应用开发》笔记
linux·开发语言·c++·笔记·qt
报错小能手6 小时前
linux学习笔记(21)线程同步——互斥锁
linux·笔记·学习
风起云涌~6 小时前
【Java】浅谈ServiceLoader
java·开发语言
那我掉的头发算什么7 小时前
【数据结构】优先级队列(堆)
java·开发语言·数据结构·链表·idea
一勺菠萝丶7 小时前
[特殊字符] IDEA 性能优化实战(32G 内存电脑专用篇)
java·性能优化·intellij-idea
Metaphor6927 小时前
Java 将 HTML 转换为 Word:告别手动复制粘贴
java·经验分享·html·word