热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

【Tomcat源码篇】自定义类加载器那点儿事儿

Tomcat进阶篇一、聊聊ClassLoader的那些事儿我们要分析清楚Tomcat中的类加载器相关的内容之前我们还是需要把JVM中的类加载器给大家理清楚。1.类加载器的过程类加
Tomcat进阶篇

在这里插入图片描述

一、聊聊ClassLoader的那些事儿

  我们要分析清楚Tomcat中的类加载器相关的内容之前我们还是需要把JVM中的类加载器给大家理清楚。

1.类加载器的过程

  类加载器的作用就是从文件系统或者网络中加载Class文件,至于他是否可以运行就不是ClassLoader的工作了。

image.png

2.类加载器的分类

  JVM中支持的类加载器有两种类型,分别是 引导类加载器【Bootstrap ClassLoader】和 自定义类加载器【User-Defined ClassLoader】

image.png

  在Java虚拟机层面定义:所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器。

image.png

可以通过源码看到对应的类加载器的继承关系

ExtClassLoader

image.png

AppClassLoader

image.png

通过具体的案例代码可以来看看类加载器的使用

public static void main(String[] args) {// 获取系统类加载器ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();System.out.println("systemClassLoader = " + systemClassLoader);// 获取父类加载器ClassLoader parent = systemClassLoader.getParent();System.out.println("parent = " + parent);// 继续获取上层的类加载器ClassLoader bootstrapClassLoader = parent.getParent();System.out.println("bootstrapClassLoader = " + bootstrapClassLoader);// 自定义Java类ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();System.out.println("classLoader = " + classLoader);// Java系统类ClassLoader classLoader1 = String.class.getClassLoader();System.out.println("classLoader1 = " + classLoader1);}

对应的输出结果

systemClassLoader = sun.misc.Launcher$AppClassLoader@18b4aac2
parent = sun.misc.Launcher$ExtClassLoader@61bbe9ba
bootstrapClassLoader = null
classLoader = sun.misc.Launcher$AppClassLoader@18b4aac2
classLoader1 = null

3.Bootstrap ClassLoader

  虚拟机自带的类加载器,启动类加载器

  • 通过C/C++实现的,JVM内置类加载器
  • 作用是用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或者sun.boot.class.path路径下的内容)、用于提供JVM自身需要的类。
  • 没有继承java.lang.ClassLoader、没有父加载器,自己就是祖先了。
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 出于安全考虑,Bootstrap启动类加载器只加载报名为java,javax,sun等开头的类

通过代码来看看具体的加载路径有哪些

public static void main(String[] args) {System.out.println("**************启动类加载器*************");URL[] urLs &#61; Launcher.getBootstrapClassPath().getURLs();for (int i &#61; 0 ; i < urLs.length ; i &#43;&#43;){URL urL &#61; urLs[i];System.out.println("urL &#61; " &#43; urL.toExternalForm());}}

输出结果&#xff1a;

**************启动类加载器*************
urL &#61; file:/D:/software/java/jdk8/jre/lib/resources.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/rt.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/sunrsasign.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/jsse.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/jce.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/charsets.jar
urL &#61; file:/D:/software/java/jdk8/jre/lib/jfr.jar
urL &#61; file:/D:/software/java/jdk8/jre/classes

4.Extension ClassLoader

  虚拟机自带的加载器。扩展类加载器&#xff0c;Java语音编写&#xff0c;是sun.misc.Launcher的内部类

image.png

派生于ClassLoader所以是自定义类加载器

image.png

父类加载器是BootstrapClassLoader。

image.png

  扩展类加载器是从扩展目录 java.ext.dirs系统属性指定的目录中加载类库&#xff0c;或者从JDK的安装目录的 jre/lib/ext子目录下加载类库&#xff0c;如果用户创建的jar包也放在了这个目录下&#xff0c;那么该类加载器也会自动加载的。

然后通过案例来看看扩展类加载器加载的路径

public static void main(String[] args) {System.out.println("**************扩展类加载器*************");String extDirs &#61; System.getProperty("java.ext.dirs");System.out.println("extDirs &#61; " &#43; extDirs);}

对应的输出结果

**************扩展类加载器*************
extDirs &#61; D:\software\java\jdk8\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext

然后通过加载路径下的Java类测试也能证明

image.png

5.App ClassLoader

  虚拟机自带的加载器&#xff0c;APPClassLoader也叫应用程序类加载器。Java语音编写&#xff0c;由sun.misc.Launcher下的内部类提供。也是派生于ClassLoader。

image.png

父类加载器为ExtClassLoader

image.png

  主要负责加载环境变量classpath或系统属性 java.class.path 指定路径下的类库&#xff0c;该类加载器是程序中的默认类加载器&#xff0c;一般来说&#xff0c;Java应用的类都是由它来完成加载的。通过 ClassLoader#getSystemClassLoader() 方法可以获取到该类加载器。

6.自定义类加载器

  在我们的日常应用程序的开发中&#xff0c;我基本是用不到自定义类加载器的&#xff0c;基本就是由前面介绍的这三个类加载器来相互配合搞定的。但是在有些特殊的情况下我们不希望通过系统的类加载器来处理&#xff0c;这时我们就可以通过自定义类加载来实现了。

  用户自定义类加载器的实现步骤&#xff1a;

  1. 我们可以直接编写 java.lang.ClassLoader 类的实现来完成自定义的处理
  2. 在JDK1.2之前&#xff0c;自定义类加载器时我们总是会继承ClassLoader然后重写loadClass方法&#xff0c;达到自定义类加载的目的&#xff0c;但是在JDK1.2之后建议把自定义的类加载逻辑放在findClass()方法中
  3. 最后在编写自定义类加载的时候&#xff0c;如果没有太过于复杂的需求&#xff0c;可以直接继承URLClassLoader类&#xff0c;这样可以达到简化自定义类加载器的目的

7.双亲委派机制


Java虚拟机对class文件采用的是 按需加载的方式&#xff0c;也就是当需要使用该类时才会将他的class文件加载到内存中生成class对象&#xff0c;而且加载某个类的class文件时&#xff0c;Java虚拟机采用的而是双亲委派模式。即把请求交由父类加载器处理&#xff0c;它是一种任务委派模式。

image.png

双亲委派的工作原理&#xff1a;

  1. 如果一个类加载器收到了类加载的请求&#xff0c;它并不会自己先去加载&#xff0c;而且把这个请求委托给父类的加载器去执行。
  2. 如果父类加载器还存在其父类加载器&#xff0c;则进一步向上委托&#xff0c;依次递归请求&#xff0c;最终将请求流转到顶层的启动类加载器
  3. 如果父类加载器可以完成类加载任务&#xff0c;就成功返回&#xff0c;如果父类无法完成任务&#xff0c;子加载器才会尝试自己去加载。

举个简单的例子&#xff0c;我们自定义一个java.lang.String类&#xff0c;然后添加对应的main方式执行。

public class String {public static void main(String[] args) {System.out.println("自定义String类");}
}

报错信息为&#xff1a;

image.png

原因就是双亲委派机制通过BootstrapClassLoader加载的是java包下的String&#xff0c;而不会加载我们自定义的。

双亲委派机制的有点&#xff1a;

  1. 避免类的重复加载
  2. 保护程序的安全&#xff0c;防止核心API被随意的篡改

二、Catalina为什么不new出来?

  掌握了Java的类加载器和双亲委派机制&#xff0c;现在我们就可以回答正题上来了&#xff0c;Tomcat的类加载器是怎么设计的&#xff1f;

1.Web容器的特性

  Web容器有其自身的特殊性&#xff0c;所以在类加载器这块是不能完全使用JVM的类加载器的双亲委派机制的。在Web容器中我们应该要满足如下的特性&#xff1a;

隔离性&#xff1a;

  部署在同一个Web容器上的两个Web应用程序所使用的Java类库可以实现相互隔离。设想一下&#xff0c;两个Web应用&#xff0c;一个使用了Spring3.0&#xff0c;另一个使用了新的的5.0&#xff0c;应用服务器使用一个类加载器&#xff0c;Web应用将会因为jar包覆盖而无法启动。

image.png

灵活性:

  Web应用之间的类加载器相互独立&#xff0c;那么就能针对一个Web应用进行重新部署&#xff0c;此时Web应用的类加载器会被重建&#xff0c;而且不会影响其他的Web应用。如果采用一个类加载器&#xff0c;类之间的依赖是杂乱复杂的&#xff0c;无法完全移出某个应用的类。

性能:

  性能也是一个比较重要的点。部署在同一个Web容器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求也很常见&#xff0c;例如&#xff0c;用户可能有10个使用Spring框架的应用程序部署在同一台服务器上&#xff0c;如果把10份Spring分别存放在各个应用程序的隔离目录中&#xff0c;将会是很大的资源浪费——这主要倒不是浪费磁盘空间的问题&#xff0c;而是指类库在使用时都要被加载到Web容器的内存&#xff0c;如果类库不能共享&#xff0c;虚拟机的方法区就会很容易出现过度膨胀的风险。

2.Tomcat类加载器结构

  明白了Web容器的类加载器有多个&#xff0c;再来看tomcat的类加载器结构。

首先上张图&#xff0c;整体看下tomcat的类加载器&#xff1a;

image.png

  可以看到在原先的java类加载器基础上&#xff0c;tomcat新增了几个类加载器&#xff0c;包括3个基础类加载器和每个Web应用的类加载器&#xff0c;其中3个基础类加载器可在conf/catalina.properties中配置&#xff0c;具体介绍下&#xff1a;

Common&#xff1a;以应用类加载器为父类&#xff0c;是tomcat顶层的公用类加载器&#xff0c;其路径由conf/catalina.properties中的common.loader指定&#xff0c;默认指向${catalina.base}/lib下的包。

image.png

Catalina&#xff1a;以Common类加载器为父类&#xff0c;是用于加载Tomcat应用服务器的类加载器&#xff0c;其路径由server.loader指定&#xff0c;默认为空&#xff0c;此时tomcat使用Common类加载器加载应用服务器。

image.png

Shared&#xff1a;以Common类加载器为父类&#xff0c;是所有Web应用的父类加载器&#xff0c;其路径由shared.loader指定&#xff0c;默认为空&#xff0c;此时tomcat使用Common类加载器作为Web应用的父加载器。

Web应用&#xff1a;以Shared类加载器为父类&#xff0c;加载/WEB-INF/classes目录下的未压缩的Class和资源文件以及/WEB-INF/lib目录下的jar包&#xff0c;该类加载器只对当前Web应用可见&#xff0c;对其他Web应用均不可见。

  默认情况下&#xff0c;Common、Catalina、Shared类加载器是同一个&#xff0c;但可以配置3个不同的类加载器&#xff0c;使他们各司其职。

  首先&#xff0c;Common类加载器复杂加载Tomcat应用服务器内部和Web应用均可见的类&#xff0c;如Servlet规范相关包和一些通用工具包。

  其次&#xff0c;Catalina类加载器负责只有Tomcat应用服务器内部可见的类&#xff0c;这些类对Web应用不可见。比如&#xff0c;想实现自己的会话存储方案&#xff0c;而且该方案依赖了一些第三方包&#xff0c;当然是不希望这些包对Web应用可见&#xff0c;这时可以配置server.load&#xff0c;创建独立的Catalina类加载器。

  再次&#xff0c;Shared类复杂加载Web应用共享类&#xff0c;这些类tomcat服务器不会依赖

3.Tomcat源码分析


3.1 CatalinClassLoader

  首先来看看Tomcat的类加载器的继承结构

image.png

可以看到继承的结构和我们上面所写的类加载器的结构不同。

大家需要注意双亲委派机制并不是通过继承来实现的&#xff0c;而是相互之间组合而形成的。所以AppClassLoader没有继承自 ExtClassLoader&#xff0c;WebappClassLoader也没有继承自AppClassLoader。至于Common ClassLoader &#xff0c;Shared ClassLoader&#xff0c;Catalina ClassLoader则是在启动时初始化的三个不同名字的URLClassLoader。

先来看看Bootstrap#init()方法。init方法会调用initClassLoaders&#xff0c;同样也会将Catalina ClassLoader设置到当前线程设置到当前线程&#xff0c;进入initClassLoaders来看看。

private void initClassLoaders() {try {// 创建 commonLoader catalinaLoader sharedLoadercommonLoader &#61; createClassLoader("common", null);if (commonLoader &#61;&#61; null) {// no config file, default to this loader - we might be in a &#39;single&#39; env.commonLoader &#61; this.getClass().getClassLoader();}// 默认情况下 server.loader 和 shared.loader 都为空则会返回 commonLoader 类加载器catalinaLoader &#61; createClassLoader("server", commonLoader);sharedLoader &#61; createClassLoader("shared", commonLoader);} catch (Throwable t) {handleThrowable(t);log.error("Class loader creation threw exception", t);System.exit(1);}}

我们可以看到在initClassLoaders()方法中完成了CommonClassLoader&#xff0c; CatalinaClassLoader&#xff0c;和SharedClassLoader的创建&#xff0c;而且进入到createClassLoader方法中。

image.png

可以看到这三个基础类加载器所加载的资源刚好对应conf/catalina.properties中的common.loader&#xff0c;server.loader&#xff0c;shared.loader

3.2 层次结构

  Common/Catalina/Shared ClassLoader的创建好了之后就会维护相互之间的组合关系

image.png

其实也就是设置了父加载器

3.3 具体的加载过程

  源码比较长&#xff0c;直接进入到 WebappClassLoaderBase中的 LoadClass方法

&#64;Overridepublic Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {synchronized (getClassLoadingLock(name)) {if (log.isDebugEnabled()) {log.debug("loadClass(" &#43; name &#43; ", " &#43; resolve &#43; ")");}Class<?> clazz &#61; null;// Log access to stopped class loadercheckStateForClassLoading(name);// (0) Check our previously loaded local class cache// 检查WebappClassLoader中是否加载过此类clazz &#61; findLoadedClass0(name);if (clazz !&#61; null) {if (log.isDebugEnabled()) {log.debug(" Returning class from cache");}if (resolve) {resolveClass(clazz);}return clazz;}// (0.1) Check our previously loaded class cache// 如果第一步没有找到&#xff0c;则继续检查JVM虚拟机中是否加载过该类clazz &#61; findLoadedClass(name);if (clazz !&#61; null) {if (log.isDebugEnabled()) {log.debug(" Returning class from cache");}if (resolve) {resolveClass(clazz);}return clazz;}// (0.2) Try loading the class with the bootstrap class loader, to prevent// the webapp from overriding Java SE classes. This implements// SRV.10.7.2// 如果前两步都没有找到&#xff0c;则使用系统类加载该类&#xff08;也就是当前JVM的ClassPath&#xff09;。// 为了防止覆盖基础类实现&#xff0c;这里会判断class是不是JVMSE中的基础类库中类。String resourceName &#61; binaryNameToPath(name, false);ClassLoader javaseLoader &#61; getJavaseClassLoader();boolean tryLoadingFromJavaseLoader;try {// Use getResource as it won&#39;t trigger an expensive// ClassNotFoundException if the resource is not available from// the Java SE class loader. However (see// https://bz.apache.org/bugzilla/show_bug.cgi?id&#61;58125 for// details) when running under a security manager in rare cases// this call may trigger a ClassCircularityError.// See https://bz.apache.org/bugzilla/show_bug.cgi?id&#61;61424 for// details of how this may trigger a StackOverflowError// Given these reported errors, catch Throwable to ensure any// other edge cases are also caughtURL url;if (securityManager !&#61; null) {PrivilegedAction<URL> dp &#61; new PrivilegedJavaseGetResource(resourceName);url &#61; AccessController.doPrivileged(dp);} else {url &#61; javaseLoader.getResource(resourceName);}tryLoadingFromJavaseLoader &#61; (url !&#61; null);} catch (Throwable t) {// Swallow all exceptions apart from those that must be re-thrownExceptionUtils.handleThrowable(t);// The getResource() trick won&#39;t work for this class. We have to// try loading it directly and accept that we might get a// ClassNotFoundException.tryLoadingFromJavaseLoader &#61; true;}if (tryLoadingFromJavaseLoader) {try {clazz &#61; javaseLoader.loadClass(name);if (clazz !&#61; null) {if (resolve) {resolveClass(clazz);}return clazz;}} catch (ClassNotFoundException e) {// Ignore}}// (0.5) Permission to access this class when using a SecurityManagerif (securityManager !&#61; null) {int i &#61; name.lastIndexOf(&#39;.&#39;);if (i >&#61; 0) {try {securityManager.checkPackageAccess(name.substring(0,i));} catch (SecurityException se) {String error &#61; sm.getString("webappClassLoader.restrictedPackage", name);log.info(error, se);throw new ClassNotFoundException(error, se);}}}// 检查是否 设置了delegate属性&#xff0c;设置为true&#xff0c;那么就会完全按照JVM的"双亲委托"机制流程加载类。boolean delegateLoad &#61; delegate || filter(name, true);// (1) Delegate to our parent if requestedif (delegateLoad) {if (log.isDebugEnabled()) {log.debug(" Delegating to parent classloader1 " &#43; parent);}try {clazz &#61; Class.forName(name, false, parent);if (clazz !&#61; null) {if (log.isDebugEnabled()) {log.debug(" Loading class from parent");}if (resolve) {resolveClass(clazz);}return clazz;}} catch (ClassNotFoundException e) {// Ignore}}// (2) Search local repositories// 若是没有委托&#xff0c;则默认会首次使用WebappClassLoader来加载类。通过自定义findClass定义处理类加载规则。// findClass()会去Web-INF/classes 目录下查找类。if (log.isDebugEnabled()) {log.debug(" Searching local repositories");}try {clazz &#61; findClass(name);if (clazz !&#61; null) {if (log.isDebugEnabled()) {log.debug(" Loading class from local repository");}if (resolve) {resolveClass(clazz);}return clazz;}} catch (ClassNotFoundException e) {// Ignore}// (3) Delegate to parent unconditionally// 若是WebappClassLoader在/WEB-INF/classes、/WEB-INF/lib下还是查找不到class&#xff0c;// 那么无条件强制委托给System、Common类加载器去查找该类。if (!delegateLoad) {if (log.isDebugEnabled()) {log.debug(" Delegating to parent classloader at end: " &#43; parent);}try {clazz &#61; Class.forName(name, false, parent);if (clazz !&#61; null) {if (log.isDebugEnabled()) {log.debug(" Loading class from parent");}if (resolve) {resolveClass(clazz);}return clazz;}} catch (ClassNotFoundException e) {// Ignore}}}throw new ClassNotFoundException(name);}

Web应用类加载器默认的加载顺序是&#xff1a;

(1).先从缓存中加载&#xff1b;

(2).如果没有&#xff0c;则从JVM的Bootstrap类加载器加载&#xff1b;

(3).如果没有&#xff0c;则从当前类加载器加载&#xff08;按照WEB-INF/classes、WEB-INF/lib的顺序&#xff09;&#xff1b;

(4).如果没有&#xff0c;则从父类加载器加载&#xff0c;由于父类加载器采用默认的委派模式&#xff0c;所以加载顺序是AppClassLoader、Common、Shared。

tomcat提供了delegate属性用于控制是否启用java委派模式&#xff0c;默认false&#xff08;不启用&#xff09;&#xff0c;当设置为true时&#xff0c;tomcat将使用java的默认委派模式&#xff0c;这时加载顺序如下&#xff1a;

(1).先从缓存中加载&#xff1b;

(2).如果没有&#xff0c;则从JVM的Bootstrap类加载器加载&#xff1b;

(3).如果没有&#xff0c;则从父类加载器加载&#xff0c;加载顺序是AppClassLoader、Common、Shared。

(4).如果没有&#xff0c;则从当前类加载器加载&#xff08;按照WEB-INF/classes、WEB-INF/lib的顺序&#xff09;&#xff1b;


推荐阅读
  • WinMain 函数详解及示例
    本文详细介绍了 WinMain 函数的参数及其用途,并提供了一个具体的示例代码来解析 WinMain 函数的实现。 ... [详细]
  • oracle c3p0 dword 60,web_day10 dbcp c3p0 dbutils
    createdatabasemydbcharactersetutf8;alertdatabasemydbcharactersetutf8;1.自定义连接池为了不去经常创建连接和释放 ... [详细]
  • 网站访问全流程解析
    本文详细介绍了从用户在浏览器中输入一个域名(如www.yy.com)到页面完全展示的整个过程,包括DNS解析、TCP连接、请求响应等多个步骤。 ... [详细]
  • 在Delphi7下要制作系统托盘,只能制作一个比较简单的系统托盘,因为ShellAPI文件定义的TNotifyIconData结构体是比较早的版本。定义如下:1234 ... [详细]
  • 开机自启动的几种方式
    0x01快速自启动目录快速启动目录自启动方式源于Windows中的一个目录,这个目录一般叫启动或者Startup。位于该目录下的PE文件会在开机后进行自启动 ... [详细]
  • 本文将带你快速了解 SpringMVC 框架的基本使用方法,通过实现一个简单的 Controller 并在浏览器中访问,展示 SpringMVC 的强大与简便。 ... [详细]
  • 本文详细介绍了如何使用OpenSSL自建CA证书的步骤,包括准备工作、生成CA证书、生成服务器待签证书以及证书签名等过程。 ... [详细]
  • 本文回顾了作者初次接触Unicode编码时的经历,并详细探讨了ASCII、ANSI、GB2312、UNICODE以及UTF-8和UTF-16编码的区别和应用场景。通过实例分析,帮助读者更好地理解和使用这些编码。 ... [详细]
  • [转]doc,ppt,xls文件格式转PDF格式http:blog.csdn.netlee353086articledetails7920355确实好用。需要注意的是#import ... [详细]
  • Framework7:构建跨平台移动应用的高效框架
    Framework7 是一个开源免费的框架,适用于开发混合移动应用(原生与HTML混合)或iOS&Android风格的Web应用。此外,它还可以作为原型开发工具,帮助开发者快速创建应用原型。 ... [详细]
  • 本文详细介绍了如何使用Python中的smtplib库来发送带有附件的邮件,并提供了完整的代码示例。作者:多测师_王sir,时间:2020年5月20日 17:24,微信:15367499889,公司:上海多测师信息有限公司。 ... [详细]
  • 本文详细介绍了 InfluxDB、collectd 和 Grafana 的安装与配置流程。首先,按照启动顺序依次安装并配置 InfluxDB、collectd 和 Grafana。InfluxDB 作为时序数据库,用于存储时间序列数据;collectd 负责数据的采集与传输;Grafana 则用于数据的可视化展示。文中提供了 collectd 的官方文档链接,便于用户参考和进一步了解其配置选项。通过本指南,读者可以轻松搭建一个高效的数据监控系统。 ... [详细]
  • 在JavaWeb开发中,文件上传是一个常见的需求。无论是通过表单还是其他方式上传文件,都必须使用POST请求。前端部分通常采用HTML表单来实现文件选择和提交功能。后端则利用Apache Commons FileUpload库来处理上传的文件,该库提供了强大的文件解析和存储能力,能够高效地处理各种文件类型。此外,为了提高系统的安全性和稳定性,还需要对上传文件的大小、格式等进行严格的校验和限制。 ... [详细]
  • 为了确保iOS应用能够安全地访问网站数据,本文介绍了如何在Nginx服务器上轻松配置CertBot以实现SSL证书的自动化管理。通过这一过程,可以确保应用始终使用HTTPS协议,从而提升数据传输的安全性和可靠性。文章详细阐述了配置步骤和常见问题的解决方法,帮助读者快速上手并成功部署SSL证书。 ... [详细]
  • CTF竞赛中文件上传技巧与安全绕过方法深入解析
    CTF竞赛中文件上传技巧与安全绕过方法深入解析 ... [详细]
author-avatar
玛丽
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有