Tomcat 架构原理解析到架构设计借鉴(下篇)
Tomcat 为何打破双亲委派机制
双亲委派
我们知道 JVM的类加载器加载 Class 的时候基于双亲委派机制,也就是会将加载交给自己的父加载器加载,如果 父加载器为空则查找Bootstrap 是否加载过,当无法加载的时候才让自己加载。JDK 提供一个抽象类 ClassLoader,这个抽象类中定义了三个关键方法。对外使用loadClass(String name) 用于子类重写打破双亲委派:loadClass(String name, boolean resolve)
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 查找该 class 是否已经被加载过
Class<?> c = findLoadedClass(name);
// 如果没有加载过
if (c == null) {
// 委托给父加载器去加载,递归调用
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// 如果父加载器为空,查找 Bootstrap 是否加载过
c = findBootstrapClassOrNull(name);
}
// 若果依然加载不到,则调用自己的 findClass 去加载
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name){
//1. 根据传入的类名 name,到在特定目录下去寻找类文件,把.class 文件读入内存
...
//2. 调用 defineClass 将字节数组转成 Class 对象
return defineClass(buf, off, len);
}
// 将字节码数组解析成一个 Class 对象,用 native 方法实现
protected final Class<?> defineClass(byte[] b, int off, int len){
...
}
JDK 中有 3 个类加载器,另外你也可以自定义类加载器,它们的关系如下图所示。
类加载器
● BootstrapClassLoader是启动类加载器,由 C 语言实现,用来加载 JVM启动时所需要的核心类,比如rt.jar、resources.jar等。
● ExtClassLoader是扩展类加载器,用来加载\jre\lib\ext目录下 JAR 包。
● AppClassLoader是系统类加载器,用来加载 classpath下的类,应用程序默认用它来加载类。
● 自定义类加载器,用来加载自定义路径下的类。
这些类加载器的工作原理是一样的,区别是它们的加载路径不同,也就是说 findClass这个方法查找的路径不同。双亲委托机制是为了保证一个 Java 类在 JVM 中是唯一的,假如你不小心写了一个与 JRE 核心类同名的类,比如 Object类,双亲委托机制能保证加载的是 JRE里的那个 Object类,而不是你写的 Object类。这是因为 AppClassLoader在加载你的 Object 类时,会委托给 ExtClassLoader去加载,而 ExtClassLoader又会委托给 BootstrapClassLoader,BootstrapClassLoader发现自己已经加载过了 Object类,会直接返回,不会去加载你写的 Object类。我们最多只能 获取到 ExtClassLoader这里注意下。
Tomcat 热加载
Tomcat 本质是通过一个后台线程做周期性的任务,定期检测类文件的变化,如果有变化就重新加载类。我们来看 ContainerBackgroundProcessor具体是如何实现的。
protected class ContainerBackgroundProcessor implements Runnable {
@Override
public void run() {
// 请注意这里传入的参数是 " 宿主类 " 的实例
processChildren(ContainerBase.this);
}
protected void processChildren(Container container) {
try {
//1. 调用当前容器的 backgroundProcess 方法。
container.backgroundProcess();
//2. 遍历所有的子容器,递归调用 processChildren,
// 这样当前容器的子孙都会被处理
Container[] children = container.findChildren();
for (int i = 0; i < children.length; i++) {
// 这里请你注意,容器基类有个变量叫做 backgroundProcessorDelay,如果大于 0,表明子容器有自己的后台线程,无需父容器来调用它的 processChildren 方法。
if (children[i].getBackgroundProcessorDelay() <= 0) {
processChildren(children[i]);
}
}
} catch (Throwable t) { ... }
Tomcat 的热加载就是在 Context 容器实现,主要是调用了 Context 容器的 reload 方法。抛开细节从宏观上看主要完成以下任务:
1.停止和销毁 Context 容器及其所有子容器,子容器其实就是 Wrapper,也就是说 Wrapper 里面 Servlet 实例也被销毁了。
2.停止和销毁 Context 容器关联的 Listener 和 Filter。
3.停止和销毁 Context 下的 Pipeline 和各种 Valve。
4.停止和销毁 Context 的类加载器,以及类加载器加载的类文件资源。
5.启动 Context 容器,在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中,类加载器发挥着关键作用。一个 Context 容器对应一个类加载器,类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context 容器在启动过程中,会创建一个新的类加载器来加载新的类文件。
Tomcat 的类加载器
Tomcat 的自定义类加载器 WebAppClassLoader打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。具体实现就是重写 ClassLoader的两个方法:findClass和 loadClass。
findClass 方法
org.apache.catalina.loader.WebappClassLoaderBase#findClass;为了方便理解和阅读,我去掉了一些细节:
public Class<?> findClass(String name) throws ClassNotFoundException {
...
Class<?> clazz = null;
try {
//1. 先在 Web 应用目录下查找类
clazz = findClassInternal(name);
} catch (RuntimeException e) {
throw e;
}
if (clazz == null) {
try {
//2. 如果在本地目录没有找到,交给父加载器去查找
clazz = super.findClass(name);
} catch (RuntimeException e) {
throw e;
}
//3. 如果父类也没找到,抛出 ClassNotFoundException
if (clazz == null) {
throw new ClassNotFoundException(name);
}
return clazz;
}
1.先在 Web 应用本地目录下查找要加载的类。
2.如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。
3.如何父加载器也没找到这个类,抛出 ClassNotFound异常。
loadClass 方法
再来看 Tomcat 类加载器的 loadClass方法的实现,同样我也去掉了一些细节:
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> clazz = null;
//1. 先在本地 cache 查找该类是否已经加载过
clazz = findLoadedClass0(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
//2. 从系统类加载器的 cache 中查找是否加载过
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
// 3. 尝试用 ExtClassLoader 类加载器类加载,为什么?
ClassLoader javaseLoader = getJavaseClassLoader();
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 4. 尝试在本地目录搜索 class 并加载
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
// 5. 尝试用系统类加载器 (也就是 AppClassLoader) 来加载
try {
clazz = Class.forName(name, false, parent);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
}
//6. 上述过程都加载失败,抛出异常
throw new ClassNotFoundException(name);
}
主要有六个步骤:
1.先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。
2.如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。
3.如果都没有,就让ExtClassLoader去加载,这一步比较关键,目的 防止 Web 应用自己的类覆盖 JRE 的核心类。因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用 ExtClassLoader去加载,因为 ExtClassLoader会委托给 BootstrapClassLoader去加载,BootstrapClassLoader发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。
4.如果 ExtClassLoader加载器加载失败,也就是说 JRE核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。
5.如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web 应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。
6.如果上述加载过程全部失败,抛出 ClassNotFound异常。
Tomcat 类加载器层次
Tomcat 作为 Servlet容器,它负责加载我们的 Servlet类,此外它还负责加载 Servlet所依赖的 JAR 包。并且 Tomcat本身也是也是一个 Java 程序,因此它需要加载自己的类和依赖的 JAR 包。首先让我们思考这一下这几个问题:
1.假如我们在 Tomcat 中运行了两个 Web 应用程序,两个 Web 应用中有同名的 Servlet,但是功能不同,Tomcat 需要同时加载和管理这两个同名的 Servlet类,保证它们不会冲突,因此 Web 应用之间的类需要隔离。
2.假如两个 Web 应用都依赖同一个第三方的 JAR 包,比如 Spring,那 Spring的 JAR 包被加载到内存后,Tomcat要保证这两个 Web 应用能够共享,也就是说 Spring的 JAR 包只被加载一次,否则随着依赖的第三方 JAR 包增多,JVM的内存会膨胀。
3.跟 JVM 一样,我们需要隔离 Tomcat 本身的类和 Web 应用的类。
1. WebAppClassLoader
Tomcat 的解决方案是自定义一个类加载器 WebAppClassLoader, 并且给每个 Web 应用创建一个类加载器实例。我们知道,Context 容器组件对应一个 Web 应用,因此,每个 Context容器负责创建和维护一个 WebAppClassLoader加载器实例。这背后的原理是,不同的加载器实例加载的类被认为是不同的类,即使它们的类名相同。这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间,每一个 Web 应用都有自己的类空间,Web 应用之间通过各自的类加载器互相隔离。
2.SharedClassLoader
本质需求是两个 Web 应用之间怎么共享库类,并且不能重复加载相同的类。在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的加载路径下不就行了吗。
因此 Tomcat 的设计者又加了一个类加载器 SharedClassLoader,作为 WebAppClassLoader的父加载器,专门来加载 Web 应用之间共享的类。如果 WebAppClassLoader自己没有加载到某个类,就会委托父加载器 SharedClassLoader去加载这个类,SharedClassLoader会在指定目录下加载共享类,之后返回给 WebAppClassLoader,这样共享的问题就解决了。
3. CatalinaClassloader
如何隔离 Tomcat 本身的类和 Web 应用的类?
要共享可以通过父子关系,要隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,基于此 Tomcat 又设计一个类加载器 CatalinaClassloader,专门来加载 Tomcat 自身的类。
这样设计有个问题,那 Tomcat 和各 Web 应用之间需要共享一些类时该怎么办呢?
老办法,还是再增加一个 CommonClassLoader,作为 CatalinaClassloader和 SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被 CatalinaClassLoader和 SharedClassLoader使用
整体架构设计解析收获总结
通过前面对 Tomcat 整体架构的学习,知道了 Tomcat 有哪些核心组件,组件之间的关系。以及 Tomcat 是怎么处理一个 HTTP 请求的。下面我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在 Tomcat 中流转的过程。
Tomcat 整体组件关系
连接器
Tomcat 的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用 ProtocolHandler接口来封装通信协议和 I/O模型的差异,ProtocolHandler内部又分为 EndPoint和 Processor模块,EndPoint负责底层 Socket通信,Proccesor负责应用层协议解析。连接器通过适配器 Adapter调用容器。
对 Tomcat 整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
容器
运用了组合模式 管理容器、通过 观察者模式 发布启动事件达到解耦、开闭原则。骨架抽象类和模板方法抽象变与不变,变化的交给子类实现,从而实现代码复用,以及灵活的拓展。使用责任链的方式处理请求,比如记录日志等。
类加载器
Tomcat 的自定义类加载器 WebAppClassLoader为了隔离 Web 应用打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。防止 Web 应用自己的类覆盖 JRE 的核心类,使用 ExtClassLoader 去加载,这样即打破了双亲委派,又能安全加载。
如何阅读源码持续学习
学习是一个反人类的过程,是比较痛苦的。尤其学习我们常用的优秀技术框架本身比较庞大,设计比较复杂,在学习初期很容易遇到 “挫折感”,debug 跳来跳去陷入恐怖细节之中无法自拔,往往就会放弃。
找到适合自己的学习方法非常重要,同样关键的是要保持学习的兴趣和动力,并且得到学习反馈效果。
学习优秀源码,我们收获的就是架构设计能力,遇到复杂需求我们学习到可以利用合理模式与组件抽象设计了可拓展性强的代码能力。
如何阅读
比如我最初在学习 Spring 框架的时候,一开始就钻进某个模块啃起来。然而由于 Spring 太庞大,模块之间也有联系,根本不明白为啥要这么写,只觉得为啥设计这么 “绕”。
错误方式
● 陷入细节,不看全局:我还没弄清楚森林长啥样,就盯着叶子看 ,看不到全貌和整体设计思路。所以阅读源码学习的时候不要一开始就进入细节,而是宏观看待整体架构设计思想,模块之间的关系。
● 还没学会用就研究如何设计:首先基本上框架都运用了设计模式,我们最起码也要了解常用的设计模式,即使是“背”,也得了然于胸。在学习一门技术,我推荐先看官方文档,看看有哪些模块、整体设计思想。然后下载示例跑一遍,最后才是看源码。
● 看源码深究细节:到了看具体某个模块源码的时候也要下意识的不要去深入细节,重要的是学习设计思路,而不是具体一个方法实现逻辑。除非自己要基于源码做二次开发。
正确方式
● 定焦原则:抓主线(抓住一个核心流程去分析,不要漫无目的的到处阅读)。
● 宏观思维:从全局的视角去看待,上帝视角理出主要核心架构设计,先森林后树叶。切勿不要试图去搞明白每一行代码。
● 断点:合理运用调用栈(观察调用过程上下文)。
带着目标去学
比如某些知识点是面试的热点,那学习目标就是彻底理解和掌握它,当被问到相关问题时,你的回答能够使得面试官对你刮目相看,有时候往往凭着某一个亮点就能影响最后的录用结果。
又或者接到一个稍微复杂的需求,学习从优秀源码中借鉴设计思路与优化技巧。
最后就是动手实践,将所学运用在工作项目中。只有动手实践才会让我们对技术有最直观的感受。有时候我们听别人讲经验和理论,感觉似乎懂了,但是过一段时间便又忘记了。
实际场景运用
简单的分析了 Tomcat 整体架构设计,从 【连接器】 到 【容器】,并且分别细说了一些组件的设计思想以及设计模式。接下来就是如何学以致用,借鉴优雅的设计运用到实际工作开发中。学习,从模仿开始。
责任链模式
在工作中,有这么一个需求,用户可以输入一些信息并可以选择查验该企业的 【工商信息】、【司法信息】、【中登情况】等如下如所示的一个或者多个模块,而且模块之间还有一些公共的东西是要各个模块复用。
这里就像一个请求,会被多个模块去处理。所以每个查询模块我们可以抽象为 处理阀门,使用一个 List 将这些 阀门保存起来,这样新增模块我们只需要新增一个阀门即可,实现了开闭原则,同时将一堆查验的代码解耦到不同的具体阀门中,使用抽象类提取 “不变的”功能。
具体示例代码如下所示:
首先抽象我们的处理阀门, NetCheckDTO是请求信息
/**
* 责任链模式:处理每个模块阀门
*/
public interface Valve {
/**
* 调用
* @param netCheckDTO
*/
void invoke(NetCheckDTO netCheckDTO);
}
定义抽象基类,复用代码。
public abstract class AbstractCheckValve implements Valve {
public final AnalysisReportLogDO getLatestHistoryData(NetCheckDTO netCheckDTO, NetCheckDataTypeEnum checkDataTypeEnum){
// 获取历史记录,省略代码逻辑
}
// 获取查验数据源配置
public final String getModuleSource(String querySource, ModuleEnum moduleEnum){
// 省略代码逻辑
}
}
定义具体每个模块处理的业务逻辑,比如 【百度负面新闻】对应的处理
@Slf4j
@Service
public class BaiduNegativeValve extends AbstractCheckValve {
@Override
public void invoke(NetCheckDTO netCheckDTO) {
}
}
最后就是管理用户选择要查验的模块,我们通过 List 保存。用于触发所需要的查验模块
@Slf4j
@Service
public class NetCheckService {
// 注入所有的阀门
@Autowired
private Map<String, Valve> valveMap;
/**
* 发送查验请求
*
* @param netCheckDTO
*/
@Async("asyncExecutor")
public void sendCheckRequest(NetCheckDTO netCheckDTO) {
// 用于保存客户选择处理的模块阀门
List<Valve> valves = new ArrayList<>();
CheckModuleConfigDTO checkModuleConfig = netCheckDTO.getCheckModuleConfig();
// 将用户选择查验的模块添加到 阀门链条中
if (checkModuleConfig.getBaiduNegative()) {
valves.add(valveMap.get("baiduNegativeValve"));
}
// 省略部分代码.......
if (CollectionUtils.isEmpty(valves)) {
log.info("网查查验模块为空,没有需要查验的任务");
return;
}
// 触发处理
valves.forEach(valve -> valve.invoke(netCheckDTO));
}
}
模板方法模式
需求是这样的,可根据客户录入的财报 excel 数据或者企业名称执行财报分析。
对于非上市的则解析 excel -> 校验数据是否合法->执行计算。
上市企业:判断名称是否存在 ,不存在则发送邮件并中止计算-> 从数据库拉取财报数据,初始化查验日志、生成一条报告记录,触发计算-> 根据失败与成功修改任务状态 。
重要的 ”变“ 与 ”不变“,
● 不变的是整个流程是初始化查验日志、初始化一条报告、前期校验数据(若是上市公司校验不通过还需要构建邮件数据并发送)、从不同来源拉取财报数据并且适配通用数据、然后触发计算,任务异常与成功都需要修改状态。
● 变化的是上市与非上市校验规则不一样,获取财报数据方式不一样,两种方式的财报数据需要适配
整个算法流程是固定的模板,但是需要将算法内部变化的部分具体实现延迟到不同子类实现,这正是模板方法模式的最佳场景。
public abstract class AbstractAnalysisTemplate {
/**
* 提交财报分析模板方法,定义骨架流程
* @param reportAnalysisRequest
* @return
*/
public final FinancialAnalysisResultDTO doProcess(FinancialReportAnalysisRequest reportAnalysisRequest) {
FinancialAnalysisResultDTO analysisDTO = new FinancialAnalysisResultDTO();
// 抽象方法:提交查验的合法校验
boolean prepareValidate = prepareValidate(reportAnalysisRequest, analysisDTO);
log.info("prepareValidate 校验结果 = {} ", prepareValidate);
if (!prepareValidate) {
// 抽象方法:构建通知邮件所需要的数据
buildEmailData(analysisDTO);
log.info("构建邮件信息,data = {}", JSON.toJSONString(analysisDTO));
return analysisDTO;
}
String reportNo = FINANCIAL_REPORT_NO_PREFIX + reportAnalysisRequest.getUserId() + SerialNumGenerator.getFixLenthSerialNumber();
// 生成分析日志
initFinancialAnalysisLog(reportAnalysisRequest, reportNo);
// 生成分析记录
initAnalysisReport(reportAnalysisRequest, reportNo);
try {
// 抽象方法:拉取财报数据,不同子类实现
FinancialDataDTO financialData = pullFinancialData(reportAnalysisRequest);
log.info("拉取财报数据完成, 准备执行计算");
// 测算指标
financialCalcContext.calc(reportAnalysisRequest, financialData, reportNo);
// 设置分析日志为成功
successCalc(reportNo);
} catch (Exception e) {
log.error("财报计算子任务出现异常", e);
// 设置分析日志失败
failCalc(reportNo);
throw e;
}
return analysisDTO;
}
}
最后新建两个子类继承该模板,并实现抽象方法。这样就将上市与非上市两种类型的处理逻辑解耦,同时又复用了代码。
策略模式
需求是这样,要做一个万能识别银行流水的 excel 接口,假设标准流水包含【交易时间、收入、支出、交易余额、付款人账号、付款人名字、收款人名称、收款人账号】等字段。现在我们解析出来每个必要字段所在 excel 表头的下标。但是流水有多种情况:
1.一种就是包含所有标准字段。
2.收入、支出下标是同一列,通过正负来区分收入与支出。
3.收入与支出是同一列,有一个交易类型的字段来区分。
4.特殊银行的特殊处理。
也就是我们要根据解析对应的下标找到对应的处理逻辑算法,我们可能在一个方法里面写超多 if else 的代码,整个流水处理都偶合在一起,假如未来再来一种新的流水类型,还要继续改老代码。最后可能出现 “又臭又长,难以维护” 的代码复杂度。
这个时候我们可以用到策略模式,将不同模板的流水使用不同的处理器处理,根据模板找到对应的策略算法去处理。即使未来再加一种类型,我们只要新加一种处理器即可,高内聚低耦合,且可拓展。
定义处理器接口,不同处理器去实现处理逻辑。将所有的处理器注入到 BankFlowDataHandler 的data_processor_map中,根据不同的场景取出对已经的处理器处理流水。
public interface DataProcessor {
/**
* 处理流水数据
* @param bankFlowTemplateDO 流水下标数据
* @param row
* @return
*/
BankTransactionFlowDO doProcess(BankFlowTemplateDO bankFlowTemplateDO, List<String> row);
/**
* 是否支持处理该模板,不同类型的流水策略根据模板数据判断是否支持解析
* @return
*/
boolean isSupport(BankFlowTemplateDO bankFlowTemplateDO);
}
// 处理器的上下文
@Service
@Slf4j
public class BankFlowDataContext {
// 将所有处理器注入到 map 中
@Autowired
private List<DataProcessor> processors;
// 找对对应的处理器处理流水
public void process() {
DataProcessor processor = getProcessor(bankFlowTemplateDO);
for(DataProcessor processor :processors) {
if (processor.isSupport(bankFlowTemplateDO)) {
// row 就是一行流水数据
processor.doProcess(bankFlowTemplateDO, row);
break;
}
}
}
}
定义默认处理器,处理正常模板,新增模板只要新增处理器实现 DataProcessor即可。
/**
* 默认处理器:正对规范流水模板
*
*/
@Component("defaultDataProcessor")
@Slf4j
public class DefaultDataProcessor implements DataProcessor {
@Override
public BankTransactionFlowDO doProcess(BankFlowTemplateDO bankFlowTemplateDO) {
// 省略处理逻辑细节
return bankTransactionFlowDO;
}
@Override
public String strategy(BankFlowTemplateDO bankFlowTemplateDO) {
// 省略判断是否支持解析该流水
boolean isDefault = true;
return isDefault;
}
}
通过策略模式,我们将不同处理逻辑分配到不同的处理类中,这样完全解耦,便于拓展。
使用内嵌 Tomcat 方式调试源代码:GitHub: https://github.com/UniqueDong/tomcat-embedded
完美分割线,由于篇幅限制对于如何借鉴 Tomcat 的设计思想运用到实际开发中的综合例子就放到下回讲解了。本篇干货满满,建议收藏以后多多回味。
文章转载自公众号:码哥字节