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

2、SOFARPC源码解析——SPI篇

SOFARPC源码解析1、SOFARPC源码解析——服务发布篇从SOFARPC源码解析——服务发布篇中来看有很多地方我都提到了SPI,那么什么是SPI呢,我们简单介绍下JAVA的S

SOFA RPC 源码解析

1、SOFA RPC 源码解析 —— 服务发布篇

从SOFA RPC 源码解析 —— 服务发布篇中来看有很多地方我都提到了SPI,那么什么是SPI呢,我们简单介绍下JAVA的SPI流程:JAVA的SPI运行流程是运用java.util.ServiceLoader这个类的load方法去在src/META-INF/services/寻找对应的全路径接口名称的文件,然后在文件中找到对应的实现方法并注入实现,然后你可以使用了。(点击JAVA SPI),话不多说,我们有了大概的spi的概念后就来看下SOFA RPC是怎么实现SPI的。

我们就从熟悉的代码开始吧:

public class RpcServer {
public static void main(String[] args) {
// 构建RegistryConfig 注册配置
RegistryConfig registryCOnfig= new RegistryConfig().setProtocol("zookeeper").setAddress("127.0.0.1:2181");
RegistryConfig registryConfig1 = new RegistryConfig().setProtocol("zookeeper").setAddress("127.0.0.1:2181");
List registryCOnfigs= new ArrayList();
registryConfigs.add(registryConfig);
registryConfigs.add(registryConfig1);
// 构建ServerConfig 服务配置
List serverCOnfigs= new ArrayList();
ServerConfig serverCOnfig= new ServerConfig().setProtocol("bolt").setPort(12200).setDaemon(false);
ServerConfig serverConfig1 = new ServerConfig().setProtocol("rest").setPort(12200).setDaemon(false);
serverConfigs.add(serverConfig);
serverConfigs.add(serverConfig1);
// 构建发布配置
ProviderConfig providerCOnfig= new ProviderConfig().setApplication(new ApplicationConfig().setAppName("paul")).setInterfaceId(HelloService.class.getName()).setRef(new HelloServiceImpl()).setServer(serverConfigs).setRegistry(registryConfig);
// 正式发布
providerConfig.export();
}
}

上面那个代码就是我第一篇SOFA RPC 源码解析 —— 服务发布篇里面的,

我们再次简单分解下,就从第一步构建RegistryConfig 注册配置里面用到的SPI来讲:

点进去RegistryConfig的父类AbstractIdConfig

/**
* 默认配置带ID
*
* @param the sub class of AbstractIdConfig
* @author GengZhang
*/
public abstract class AbstractIdConfig implements Serializable {
private static final long serialVersiOnUID= -1932911135229369183L;
/**
* Id生成器
*/
private final static AtomicInteger ID_GENERATOR = new AtomicInteger(0);
static {
RpcRuntimeContext.now();
}
...

再点进去静态模块里的RpcRuntimeContext,找到:

static {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Welcome! Loading SOFA RPC Framework : {}, PID is:{}", Version.BUILD_VERSION, PID);
}
put(RpcConstants.CONFIG_KEY_RPC_VERSION, Version.RPC_VERSION);
// 初始化一些上下文
initContext();
// 初始化其它模块
ModuleFactory.installModules();
// 增加jvm关闭事件
if (RpcConfigs.getOrDefaultValue(RpcOptions.JVM_SHUTDOWN_HOOK, true)) {
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run() {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn("SOFA RPC Framework catch JVM shutdown event, Run shutdown hook now.");
}
destroy(false);
}
}, "SOFA-RPC-ShutdownHook"));
}
}

这里面的 ModuleFactory.installModules();就用到了SPI,根据配置加载扩展模块,一起来看看吧:

/**
* 加载全部模块
*/
public static void installModules() {
ExtensionLoader loader = ExtensionLoaderFactory.getExtensionLoader(Module.class);
String moduleLoadList = RpcConfigs.getStringValue(RpcOptions.MODULE_LOAD_LIST);
for (Map.Entry> o : loader.getAllExtensions().entrySet()) {
String moduleName = o.getKey();
Module module = o.getValue().getExtInstance();
// judge need load from rpc option
if (needLoad(moduleLoadList, moduleName)) {
// judge need load from implement
if (module.needLoad()) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Install Module: {}", moduleName);
}
module.install();
INSTALLED_MODULES.put(moduleName, module);
} else {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("The module " + moduleName + " does not need to be loaded.");
}
}
} else {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("The module " + moduleName + " is not in the module load list.");
}
}
}
}

上述代码里,ExtensionLoader loader = ExtensionLoaderFactory.getExtensionLoader(Module.class);里面就是SPI的核心

/**
* Get extension loader by extensible class with listener
*
* @param clazz Extensible class
* @param listener Listener of ExtensionLoader
* @param Class
* @return ExtensionLoader of this class
*/
public static ExtensionLoader getExtensionLoader(Class clazz, ExtensionLoaderListener listener) {
// 第一次进来loader 肯定是空的
ExtensionLoader loader = LOADER_MAP.get(clazz);
if (loader == null) {
// 锁住class,双重校验,防止重复初始化
synchronized (ExtensionLoaderFactory.class) {
loader = LOADER_MAP.get(clazz);
if (loader == null) {
// 实例化 loader
loader = new ExtensionLoader(clazz, listener);
LOADER_MAP.put(clazz, loader);
}
}
}
return loader;
}
/**
* Get extension loader by extensible class without listener
*
* @param clazz Extensible class
* @param Class
* @return ExtensionLoader of this class
*/
public static ExtensionLoader getExtensionLoader(Class clazz) {
return getExtensionLoader(clazz, null);
}

上面我写了简单的注释,最重要的一句话就是loader = new ExtensionLoader(clazz, listener);我们点进去看看它到底做了些什么吧!

/**
* 构造函数(自动加载)
*
* @param interfaceClass 接口类
* @param listener 加载后的监听器
*/
public ExtensionLoader(Class interfaceClass, ExtensionLoaderListener listener) {
this(interfaceClass, true, listener);
}

/**
* 构造函数(主要测试用)
*
* @param interfaceClass 接口类
* @param autoLoad 是否自动开始加载
* @param listener 扩展加载监听器
*/
protected ExtensionLoader(Class interfaceClass, boolean autoLoad, ExtensionLoaderListener listener) {
// 如果RPC框架正在关闭则属性全部初始化为空return
if (RpcRunningState.isShuttingDown()) {
this.interfaceClass = null;
this.interfaceName = null;
this.listener = null;
this.factory = null;
this.extensible = null;
this.all = null;
return;
}
// 接口为空,既不是接口,也不是抽象类,要求必须是接口或者抽象类,会自动加载所有实现类
if (interfaceClass == null ||
!(interfaceClass.isInterface() || Modifier.isAbstract(interfaceClass.getModifiers()))) {
throw new IllegalArgumentException("Extensible class must be interface or abstract class!");
}
this.interfaceClass = interfaceClass;
this.interfaceName = ClassTypeUtils.getTypeStr(interfaceClass);
this.listener = listener;
// 获取extensible注解,上面会有几个属性file指定扩展文件名称,singleton是否单例,coded是否需要编码
Extensible extensible = interfaceClass.getAnnotation(Extensible.class);
if (extensible == null) {
throw new IllegalArgumentException(
"Error when load extensible interface " + interfaceName + ", must add annotation @Extensible.");
} else {
this.extensible = extensible;
}
// 如果是单例的,则存入factory,也就是一个线程安全的ConcurrentHashMap
this.factory = extensible.singleton() ? new ConcurrentHashMap() : null;
// 初始化一个保存全部扩展的对象的ConcurrentMap
this.all = new ConcurrentHashMap>();
// 是否自动加载,一般都是是
if (autoLoad) {
// 从配置中心或者配置文件中加载扩展类文件相对路径
List paths = RpcConfigs.getListValue(RpcOptions.EXTENSION_LOAD_PATH);
for (String path : paths) {
// 这个就是最重要的一步,解析文件!
loadFromFile(path);
}
}
}

/**
* @param path path必须以/结尾
*/
protected synchronized void loadFromFile(String path) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Loading extension of extensible {} from path: {}", interfaceName, path);
}
// 默认如果不指定文件名字,就是接口名
String file = StringUtils.isBlank(extensible.file()) ? interfaceName : extensible.file().trim();
// 获得完整的相对地址
String fullFileName = path + file;
try {
// 获得当前类的类加载器,这个是用来获取resource的也就是获取资源文件
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(getClass());
loadFromClassLoader(classLoader, fullFileName);
} catch (Throwable t) {
if (LOGGER.isErrorEnabled()) {
LOGGER.error("Failed to load extension of extensible " + interfaceName + " from path:" + fullFileName,
t);
}
}
}

protected void loadFromClassLoader(ClassLoader classLoader, String fullFileName) throws Throwable {
// 根据全路径名从classLoader里面获取资源文件
Enumeration urls = classLoader != null ? classLoader.getResources(fullFileName)
: ClassLoader.getSystemResources(fullFileName);
// 可能存在多个文件。
if (urls != null) {
while (urls.hasMoreElements()) {
// 读取一个文件
URL url = urls.nextElement();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Loading extension of extensible {} from classloader: {} and file: {}",
interfaceName, classLoader, url);
}
BufferedReader reader = null;
try {
reader = new BufferedReader(new InputStreamReader(url.openStream(), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
// 读取处理逻辑
readLine(url, line);
}
} catch (Throwable t) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn("Failed to load extension of extensible " + interfaceName
+ " from classloader: " + classLoader + " and file:" + url, t);
}
} finally {
if (reader != null) {
reader.close();
}
}
}
}
}

protected void readLine(URL url, String line) {
// 解析一行,获取他们的别名和className,这个方法里对空做了处理,别名为空时仍可正常返回
String[] aliasAndClassName = parseAliasAndClassName(line);
if (aliasAndClassName == null || aliasAndClassName.length != 2) {
return;
}
String alias = aliasAndClassName[0];
String className = aliasAndClassName[1];
// 读取配置的实现类
Class tmp;
try {
tmp = ClassUtils.forName(className, false);
} catch (Throwable e) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn("Extension {} of extensible {} is disabled, cause by: {}",
className, interfaceName, ExceptionUtils.toShortString(e, 2));
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Extension " + className + " of extensible " + interfaceName + " is disabled.", e);
}
return;
}
// 用来判断A类是否是B类的子类或者子接口,Object是所有类的父类
// [isAssignableFrom](https://www.cnblogs.com/paul-lb/p/11344584.html)
if (!interfaceClass.isAssignableFrom(tmp)) {
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceName +
" from file:" + url + ", " + className + " is not subtype of interface.");
}
Class implClass = (Class) tmp;
// 检查是否有可扩展标识,就是子类上面的Extension 注解
Extension extension = implClass.getAnnotation(Extension.class);
if (extension == null) {
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceName +
" from file:" + url + ", " + className + " must add annotation @Extension.");
} else {
// 获取注解上面的扩展点名字必须写,不写就会报错
String aliasInCode = extension.value();
if (StringUtils.isBlank(aliasInCode)) {
// 扩展实现类未配置@Extension 标签
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceClass +
" from file:" + url + ", " + className + "'s alias of @Extension is blank");
}
// 从配置文件取的别名,前面说了如果别名为空仍可正常返回
if (alias == null) {
// spi文件里没配置,用代码里的
alias = aliasInCode;
} else {
// spi文件里配置的和代码里的不一致
if (!aliasInCode.equals(alias)) {
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceName +
" from file:" + url + ", aliases of " + className + " are " +
"not equal between " + aliasInCode + "(code) and " + alias + "(file).");
}
}
// 接口需要编号,实现类没设置
if (extensible.coded() && extension.code() <0) {
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceName +
" from file:" + url + ", code of @Extension must >=0 at " + className + ".");
}
}
// 不可以是default和*
if (StringUtils.DEFAULT.equals(alias) || StringUtils.ALL.equals(alias)) {
throw new IllegalArgumentException("Error when load extension of extensible " + interfaceName +
" from file:" + url + ", alias of @Extension must not \"default\" and \"*\" at " + className + ".");
}
// 检查是否有存在同名的
ExtensionClass old = all.get(alias);
ExtensionClass extensiOnClass= null;
if (old != null) {
// 如果当前扩展可以覆盖其它同名扩展
if (extension.override()) {
// 如果优先级还没有旧的高,则忽略
if (extension.order() if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Extension of extensible {} with alias {} override from {} to {} failure, " +
"cause by: order of old extension is higher",
interfaceName, alias, old.getClazz(), implClass);
}
} else {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Extension of extensible {} with alias {}: {} has been override to {}",
interfaceName, alias, old.getClazz(), implClass);
}
// 如果当前扩展可以覆盖其它同名扩展
extensiOnClass= buildClass(extension, implClass, alias);
}
}
// 如果旧扩展是可覆盖的
else {
if (old.isOverride() && old.getOrder() >= extension.order()) {
// 如果已加载覆盖扩展,再加载到原始扩展
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Extension of extensible {} with alias {}: {} has been loaded, ignore origin {}",
interfaceName, alias, old.getClazz(), implClass);
}
} else {
// 如果不能被覆盖,抛出已存在异常
throw new IllegalStateException(
"Error when load extension of extensible " + interfaceClass + " from file:" + url +
", Duplicate class with same alias: " + alias + ", " + old.getClazz() + " and " + implClass);
}
}
} else {
extensiOnClass= buildClass(extension, implClass, alias);
}
if (extensionClass != null) {
// 检查是否有互斥的扩展点
for (Map.Entry> entry : all.entrySet()) {
ExtensionClass existed = entry.getValue();
if (extensionClass.getOrder() >= existed.getOrder()) {
// 新的优先级 >= 老的优先级,检查新的扩展是否排除老的扩展
String[] rejection = extensionClass.getRejection();
if (CommonUtils.isNotEmpty(rejection)) {
for (String rej : rejection) {
existed = all.get(rej);
if (existed == null || extensionClass.getOrder() continue;
}
ExtensionClass removed = all.remove(rej);
if (removed != null) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info(
"Extension of extensible {} with alias {}: {} has been reject by new {}",
interfaceName, removed.getAlias(), removed.getClazz(), implClass);
}
}
}
}
} else {
String[] rejection = existed.getRejection();
if (CommonUtils.isNotEmpty(rejection)) {
for (String rej : rejection) {
if (rej.equals(extensionClass.getAlias())) {
// 被其它扩展排掉
if (LOGGER.isInfoEnabled()) {
LOGGER.info(
"Extension of extensible {} with alias {}: {} has been reject by old {}",
interfaceName, alias, implClass, existed.getClazz());
return;
}
}
}
}
}
}
loadSuccess(alias, extensionClass);
}
}

// 将实现类class别名等属性初始化到ExtensionClass里
private ExtensionClass buildClass(Extension extension, Class implClass, String alias) {
ExtensionClass extensiOnClass= new ExtensionClass(implClass, alias);
extensionClass.setCode(extension.code());
extensionClass.setSingleton(extensible.singleton());
extensionClass.setOrder(extension.order());
extensionClass.setOverride(extension.override());
extensionClass.setRejection(extension.rejection());
return extensionClass;
}
// 如果有监听器则通知监听器,加入全部的加载的实现类 {"alias":ExtensionClass} all
private void loadSuccess(String alias, ExtensionClass extensionClass) {
if (listener != null) {
try {
listener.onLoad(extensionClass); // 加载完毕,通知监听器
all.put(alias, extensionClass);
} catch (Exception e) {
LOGGER.error("Error when load extension of extensible " + interfaceClass + " with alias: "
+ alias + ".", e);
}
} else {
all.put(alias, extensionClass);
}
}

讲到这里我们spi的加载过程就讲完了,我们现在来看下他最后的操作,怎么取初始化获得加载的class对象呢?

/**
* 加载全部模块
*/
public static void installModules() {
ExtensionLoader loader = ExtensionLoaderFactory.getExtensionLoader(Module.class);
String moduleLoadList = RpcConfigs.getStringValue(RpcOptions.MODULE_LOAD_LIST);
for (Map.Entry> o : loader.getAllExtensions().entrySet()) {
String moduleName = o.getKey();
// 从这里可以看出来调用的是getExtInstance()
Module module = o.getValue().getExtInstance();
// judge need load from rpc option
if (needLoad(moduleLoadList, moduleName)) {
// judge need load from implement
if (module.needLoad()) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Install Module: {}", moduleName);
}
module.install();
INSTALLED_MODULES.put(moduleName, module);
} else {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("The module " + moduleName + " does not need to be loaded.");
}
}
} else {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("The module " + moduleName + " is not in the module load list.");
}
}
}
}


/**
* 得到服务端实例对象,如果是单例则返回单例对象,如果不是则返回新创建的实例对象
*
* @return 扩展点对象实例
*/
public T getExtInstance() {
return getExtInstance(null, null);
}

/**
* 得到服务端实例对象,如果是单例则返回单例对象,如果不是则返回新创建的实例对象
*
* @param argTypes 构造函数参数类型
* @param args 构造函数参数值
* @return 扩展点对象实例 ext instance
*/
public T getExtInstance(Class[] argTypes, Object[] args) {
if (clazz != null) {
try {
// 这个singleton是在加载spi的时候从extensible上面取出注入的哦
if (singleton) { // 如果是单例
if (instance == null) {
synchronized (this) {
if (instance == null) {
// 这里面是自动检测构造方法是否跟传的参数一直,一直则使用该构造器构造对象
instance = ClassUtils.newInstanceWithArgs(clazz, argTypes, args);
}
}
}
return instance; // 保留单例
} else {
// 这里面是自动检测构造方法是否跟传的参数一直,一直则使用该构造器构造对象
return ClassUtils.newInstanceWithArgs(clazz, argTypes, args);
}
} catch (Exception e) {
throw new SofaRpcRuntimeException("create " + clazz.getCanonicalName() + " instance error", e);
}
}
throw new SofaRpcRuntimeException("Class of ExtensionClass is null");
}

从上面的分析可以看出来对spi扩展类的初始化都是根据所传参数自动调用构造器去构造出来的对象,只是区分了单例和非单例的扩展类,单例使用了锁+双重检查的方式,保证只会初始化一次!

好了,今天我们所有的SOFA RPC SPI的分析到这里就结束了,看完是不是觉得还挺简单的,下面我来小结下全过程:

1、根据传入的扩展类class接口获得到类名,Extensible注解

2、根据Extensible是否填写file去配置的相对路径下加载相应的扩展文件,并获得一个length为2的数组

3、根据数组中配置的className去加载相应的class对象,并判断该对象是否是传入的class的子类

4、根据extension上的配置去检查同名覆盖、互斥扩展点等校验

5、将数据初始化到ExtensionClass上

6、将获得的别名和ExtensionClass存入ConcurrentMap中以备下次使用

总结结束,是不是很简单!


推荐阅读
  • 本文介绍了使用Java实现大数乘法的分治算法,包括输入数据的处理、普通大数乘法的结果和Karatsuba大数乘法的结果。通过改变long类型可以适应不同范围的大数乘法计算。 ... [详细]
  • 本文讨论了如何优化解决hdu 1003 java题目的动态规划方法,通过分析加法规则和最大和的性质,提出了一种优化的思路。具体方法是,当从1加到n为负时,即sum(1,n)sum(n,s),可以继续加法计算。同时,还考虑了两种特殊情况:都是负数的情况和有0的情况。最后,通过使用Scanner类来获取输入数据。 ... [详细]
  • 猜字母游戏
    猜字母游戏猜字母游戏——设计数据结构猜字母游戏——设计程序结构猜字母游戏——实现字母生成方法猜字母游戏——实现字母检测方法猜字母游戏——实现主方法1猜字母游戏——设计数据结构1.1 ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • Nginx使用(server参数配置)
    本文介绍了Nginx的使用,重点讲解了server参数配置,包括端口号、主机名、根目录等内容。同时,还介绍了Nginx的反向代理功能。 ... [详细]
  • 本文介绍了Java工具类库Hutool,该工具包封装了对文件、流、加密解密、转码、正则、线程、XML等JDK方法的封装,并提供了各种Util工具类。同时,还介绍了Hutool的组件,包括动态代理、布隆过滤、缓存、定时任务等功能。该工具包可以简化Java代码,提高开发效率。 ... [详细]
  • 本文介绍了OC学习笔记中的@property和@synthesize,包括属性的定义和合成的使用方法。通过示例代码详细讲解了@property和@synthesize的作用和用法。 ... [详细]
  • Mac OS 升级到11.2.2 Eclipse打不开了,报错Failed to create the Java Virtual Machine
    本文介绍了在Mac OS升级到11.2.2版本后,使用Eclipse打开时出现报错Failed to create the Java Virtual Machine的问题,并提供了解决方法。 ... [详细]
  • 在说Hibernate映射前,我们先来了解下对象关系映射ORM。ORM的实现思想就是将关系数据库中表的数据映射成对象,以对象的形式展现。这样开发人员就可以把对数据库的操作转化为对 ... [详细]
  • Spring特性实现接口多类的动态调用详解
    本文详细介绍了如何使用Spring特性实现接口多类的动态调用。通过对Spring IoC容器的基础类BeanFactory和ApplicationContext的介绍,以及getBeansOfType方法的应用,解决了在实际工作中遇到的接口及多个实现类的问题。同时,文章还提到了SPI使用的不便之处,并介绍了借助ApplicationContext实现需求的方法。阅读本文,你将了解到Spring特性的实现原理和实际应用方式。 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • 后台获取视图对应的字符串
    1.帮助类后台获取视图对应的字符串publicclassViewHelper{将View输出为字符串(注:不会执行对应的ac ... [详细]
  • 本文介绍了通过ABAP开发往外网发邮件的需求,并提供了配置和代码整理的资料。其中包括了配置SAP邮件服务器的步骤和ABAP写发送邮件代码的过程。通过RZ10配置参数和icm/server_port_1的设定,可以实现向Sap User和外部邮件发送邮件的功能。希望对需要的开发人员有帮助。摘要长度:184字。 ... [详细]
  • [大整数乘法] java代码实现
    本文介绍了使用java代码实现大整数乘法的过程,同时也涉及到大整数加法和大整数减法的计算方法。通过分治算法来提高计算效率,并对算法的时间复杂度进行了研究。详细代码实现请参考文章链接。 ... [详细]
  • flowable工作流 流程变量_信也科技工作流平台的技术实践
    1背景随着公司业务发展及内部业务流程诉求的增长,目前信息化系统不能够很好满足期望,主要体现如下:目前OA流程引擎无法满足企业特定业务流程需求,且移动端体 ... [详细]
author-avatar
手机用户2502901575_836
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有