热门标签 | 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中以备下次使用

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


推荐阅读
  • 本文详细介绍了如何解决DNS服务器配置转发无法解析的问题,包括编辑主配置文件和重启域名服务的具体步骤。 ... [详细]
  • 网站访问全流程解析
    本文详细介绍了从用户在浏览器中输入一个域名(如www.yy.com)到页面完全展示的整个过程,包括DNS解析、TCP连接、请求响应等多个步骤。 ... [详细]
  • 微软推出Windows Terminal Preview v0.10
    微软近期发布了Windows Terminal Preview v0.10,用户可以在微软商店或GitHub上获取这一更新。该版本在2月份发布的v0.9基础上,新增了鼠标输入和复制Pane等功能。 ... [详细]
  • Framework7:构建跨平台移动应用的高效框架
    Framework7 是一个开源免费的框架,适用于开发混合移动应用(原生与HTML混合)或iOS&Android风格的Web应用。此外,它还可以作为原型开发工具,帮助开发者快速创建应用原型。 ... [详细]
  • 本文介绍了如何使用 CMD 批处理脚本进行文件操作,包括将指定目录下的 PHP 文件重命名为 HTML 文件,并将这些文件复制到另一个目录。 ... [详细]
  • 解决Parallels Desktop错误15265的方法
    本文详细介绍了在使用Parallels Desktop时遇到错误15265的多种解决方案,包括检查网络连接、关闭代理服务器和修改主机文件等步骤。 ... [详细]
  • 解决 Windows Server 2016 网络连接问题
    本文详细介绍了如何解决 Windows Server 2016 在使用无线网络 (WLAN) 和有线网络 (以太网) 时遇到的连接问题。包括添加必要的功能和安装正确的驱动程序。 ... [详细]
  • CentOS 7 中 iptables 过滤表实例与 NAT 表应用详解
    在 CentOS 7 系统中,iptables 的过滤表和 NAT 表具有重要的应用价值。本文通过具体实例详细介绍了如何配置 iptables 的过滤表,包括编写脚本文件 `/usr/local/sbin/iptables.sh`,并使用 `iptables -F` 清空现有规则。此外,还深入探讨了 NAT 表的配置方法,帮助读者更好地理解和应用这些网络防火墙技术。 ... [详细]
  • 在使用Eclipse进行调试时,如果遇到未解析的断点(unresolved breakpoint)并显示“未加载符号表,请使用‘file’命令加载目标文件以进行调试”的错误提示,这通常是因为调试器未能正确加载符号表。解决此问题的方法是通过GDB的`file`命令手动加载目标文件,以便调试器能够识别和解析断点。具体操作为在GDB命令行中输入 `(gdb) file `。这一步骤确保了调试环境能够正确访问和解析程序中的符号信息,从而实现有效的调试。 ... [详细]
  • 自动验证时页面显示问题的解决方法
    在使用自动验证功能时,页面未能正确显示错误信息。通过使用 `dump($info->getError())` 可以帮助诊断和解决问题。 ... [详细]
  • Python 数据可视化实战指南
    本文详细介绍如何使用 Python 进行数据可视化,涵盖从环境搭建到具体实例的全过程。 ... [详细]
  • 第二十五天接口、多态
    1.java是面向对象的语言。设计模式:接口接口类是从java里衍生出来的,不是python原生支持的主要用于继承里多继承抽象类是python原生支持的主要用于继承里的单继承但是接 ... [详细]
  • 在 LeetCode 的“有效回文串 II”问题中,给定一个非空字符串 `s`,允许删除最多一个字符。本篇深入解析了如何判断删除一个字符后,字符串是否能成为回文串,并提出了高效的优化算法。通过详细的分析和代码实现,本文提供了多种解决方案,帮助读者更好地理解和应用这一算法。 ... [详细]
  • 系统数据实体验证异常:多个实体验证失败的错误处理与分析
    在使用MVC和EF框架进行数据保存时,遇到了 `System.Data.Entity.Validation.DbEntityValidationException` 错误,表明存在一个或多个实体验证失败的情况。本文详细分析了该错误的成因,并提出了有效的处理方法,包括检查实体属性的约束条件、调试日志的使用以及优化数据验证逻辑,以确保数据的一致性和完整性。 ... [详细]
  • MySQL的查询执行流程涉及多个关键组件,包括连接器、查询缓存、分析器和优化器。在服务层,连接器负责建立与客户端的连接,查询缓存用于存储和检索常用查询结果,以提高性能。分析器则解析SQL语句,生成语法树,而优化器负责选择最优的查询执行计划。这一流程确保了MySQL能够高效地处理各种复杂的查询请求。 ... [详细]
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社区 版权所有