dubbo源码之SPI AdaptiveExtension和Wrapper
dubbo 中SPI 拓展这一节中,@Adaptive是相当重要的一部分,ExtensionLoader构造器中会调用getAdaptiveExtension()方法触发为当前扩展类型生成适配器,用到的是动态代理技术。dubbo中是通过javassist技术生成的动态代理类。与传统的jdk动态代理、cglib不同,javassist提供封装后的API对字节码进行间接操作,简单易用,不关心具体字节码,灵活性更高,且处理效率也较高,是dubbo默认的编译器。
1. 前言
在之前的推文中我们知道,dubbo有很多SPI的拓展点,而ExtensionLoader又是dubbo SPI拓展点的加载器。这篇文章中我们将以ExtensionLoader为切入点来对dubbo的SPI机制进行分析。dubbo中的SPI拓展点的主要位置在:
- META-INF/services/ services拓展点目录
- META-INF/dubbo/ 一般自定义的放在这里
- META-INF/dubbo/internal/ 内部拓展点目录
在META-INF/dubbo/internal/目录里有主要有:
2. 流程分析
在com.alibaba.dubbo.config.ServiceConfig类中有这样几个属性:
这里是通过拓展点加载器ExtensionLoader来加载Protocol和ProxyFactory,我们来针对Protocol为例来看一看具体流程: Protocol类的定义为:
Protocol的spi拓展文件如下:
在它的export和refer方法上都加了@Adaptive注解。下面进行具体分析。
- private static final Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension();
- com.alibaba.dubbo.common.extension.ExtensionLoader#getExtensionLoader方法:
public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) { if (type == null) throw new IllegalArgumentException("Extension type == null"); if(!type.isInterface()) { throw new IllegalArgumentException("Extension type(" + type + ") is not interface!"); } if(!withExtensionAnnotation(type)) { throw new IllegalArgumentException("Extension type(" + type + ") is not extension, because WITHOUT @" + SPI.class.getSimpleName() + " Annotation!"); } ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type); if (loader == null) { EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type)); loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type); } return loader; }
这里是获取(先在缓存中取,如果缓存中取不到就新建,然后放入缓存并返回)指定type的ExtensionLoader的代码。
- com.alibaba.dubbo.common.extension.ExtensionLoader#getAdaptiveExtension方法:
public T getAdaptiveExtension() { Object instance = cachedAdaptiveInstance.get(); if (instance == null) { if(createAdaptiveInstanceError == null) { synchronized (cachedAdaptiveInstance) {//double check 获取protocol 类对应的adaptiveExtension实例(先取缓存,缓存中没有则新建并放入缓存和返回) instance = cachedAdaptiveInstance.get(); if (instance == null) { try { instance = createAdaptiveExtension(); cachedAdaptiveInstance.set(instance); } catch (Throwable t) { createAdaptiveInstanceError = t; throw new IllegalStateException("fail to create adaptive instance: " + t.toString(), t); } } } } else { throw new IllegalStateException("fail to create adaptive instance: " + createAdaptiveInstanceError.toString(), createAdaptiveInstanceError); } } return (T) instance; }
这一段是double check 获取protocol 类对应的adaptiveExtension实例(先取缓存,缓存中没有则新建并放入缓存和返回),在SPI介绍的那一篇有提到过这个。下面我们看一下createAdaptiveExtension(),因为第一次进来肯定是要走这个方法的:
private T createAdaptiveExtension() { try { //IOC return injectExtension((T) getAdaptiveExtensionClass().newInstance()); } catch (Exception e) { throw new IllegalStateException("Can not create adaptive extenstion " + type + ", cause: " + e.getMessage(), e); }}
将getAdaptiveExtensionClass().newInstance()生成的实例通过injectExtension进行注入(IOC)。这里注入的是根据cachedAdaptiveClass生成的实例,请关注下文对cachedAdaptiveClass的说明。进入getAdaptiveExtensionClass方法:
private Class<?> getAdaptiveExtensionClass() { getExtensionClasses();//加载拓展文件中的classes if (cachedAdaptiveClass != null) { return cachedAdaptiveClass; } return cachedAdaptiveClass = createAdaptiveExtensionClass();//创建AdaptiveExtensionClass}
- 先来看一看getExtensionClasses()方法:
private Map<String, Class<?>> getExtensionClasses() { Map<String, Class<?>> classes = cachedClasses.get(); if (classes == null) { synchronized (cachedClasses) { classes = cachedClasses.get(); if (classes == null) { classes = loadExtensionClasses(); cachedClasses.set(classes); } } } return classes; }
double check 加载classes的过程,接下来看一看loadExtensionClasses()的实现:
private static final String SERVICES_DIRECTORY = "META-INF/services/"; private static final String DUBBO_DIRECTORY = "META-INF/dubbo/"; private static final String DUBBO_INTERNAL_DIRECTORY = DUBBO_DIRECTORY + "internal/";// 此方法已经getExtensionClasses方法同步过。 private Map<String, Class<?>> loadExtensionClasses() { final SPI defaultAnnotation = type.getAnnotation(SPI.class); if(defaultAnnotation != null) { String value = defaultAnnotation.value(); if(value != null && (value = value.trim()).length() > 0) { String[] names = NAME_SEPARATOR.split(value); if(names.length > 1) { throw new IllegalStateException("more than 1 default extension name on extension " + type.getName() + ": " + Arrays.toString(names)); } if(names.length == 1) cachedDefaultName = names[0]; } } Map<String, Class<?>> extensionClasses = new HashMap<String, Class<?>>(); loadFile(extensionClasses, DUBBO_INTERNAL_DIRECTORY); loadFile(extensionClasses, DUBBO_DIRECTORY); loadFile(extensionClasses, SERVICES_DIRECTORY); return extensionClasses; }
这个方法主要从上面三个spi目录中加载文件,并将class文件分类放好,具体分类的过程在loadFile方法中:
/*
* 提示:该行代码过长,系统自动注释不进行高亮。一键复制会移除系统注释
* private void loadFile(Map<String, Class<?>> extensionClasses, String dir) { String fileName = dir + type.getName();//会从目录的type.getName对应的目录中去加载对应的文件 try { Enumeration<java.net.URL> urls; ClassLoader classLoader = findClassLoader(); if (classLoader != null) { urls = classLoader.getResources(fileName); } else { urls = ClassLoader.getSystemResources(fileName); } if (urls != null) { while (urls.hasMoreElements()) { java.net.URL url = urls.nextElement(); try { BufferedReader reader = new BufferedReader(new InputStreamReader(url.openStream(), "utf-8")); try { String line = null; while ((line = reader.readLine()) != null) { final int ci = line.indexOf('#'); if (ci >= 0) line = line.substring(0, ci); line = line.trim(); if (line.length() > 0) { try { String name = null; int i = line.indexOf('='); if (i > 0) { name = line.substring(0, i).trim(); line = line.substring(i + 1).trim(); } if (line.length() > 0) { Class<?> clazz = Class.forName(line, true, classLoader); if (! type.isAssignableFrom(clazz)) {//如果加载到clazz不是type的子类,则抛异常 throw new IllegalStateException("Error when load extension class(interface: " + type + ", class line: " + clazz.getName() + "), class " + clazz.getName() + "is not subtype of interface."); } if (clazz.isAnnotationPresent(Adaptive.class)) {//如果类上面加了@Adaptive注解,则将这个类设置成cachedAdaptiveClass if(cachedAdaptiveClass == null) { cachedAdaptiveClass = clazz; } else if (! cachedAdaptiveClass.equals(clazz)) {//如果这个clazz和之前设置的cachedAdaptiveClass不一致则报错。也就是说一个像Protocol这样的父接口,它的实现类上只能有一个加@Adaptive注解 throw new IllegalStateException("More than 1 adaptive class found: " + cachedAdaptiveClass.getClass().getName() + ", " + clazz.getClass().getName()); } } else { try { clazz.getConstructor(type);//如果类上面没有加@Adaptive注解,则尝试获取这个类上有无带有父接口(type)类型的参数的构造方法,如果有那么这个实现类就属于使用装饰器模式装饰过的wrappers,放入cachedWrapperClasses中去 Set<Class<?>> wrappers = cachedWrapperClasses; if (wrappers == null) { cachedWrapperClasses = new ConcurrentHashSet<Class<?>>(); wrappers = cachedWrapperClasses; } wrappers.add(clazz); } catch (NoSuchMethodException e) { //没有对应的构造方法,则调用无参的构造方法 clazz.getConstructor(); if (name == null || name.length() == 0) { name = findAnnotationName(clazz); if (name == null || name.length() == 0) { //实现类的className要比type的大 if (clazz.getSimpleName().length() > type.getSimpleName().length() && clazz.getSimpleName().endsWith(type.getSimpleName())) { name = clazz.getSimpleName().substring(0, clazz.getSimpleName().length() - type.getSimpleName().length()).toLowerCase(); } else { throw new IllegalStateException("No such extension name for the class " + clazz.getName() + " in the config " + url); } } } String[] names = NAME_SEPARATOR.split(name); if (names != null && names.length > 0) { //如果加了Activate注解则放入cachedActivates目录中 Activate activate = clazz.getAnnotation(Activate.class); if (activate != null) { cachedActivates.put(names[0], activate); } for (String n : names) { if (! cachedNames.containsKey(clazz)) { //如果没有Activate注解则放入cachedNames cachedNames.put(clazz, n); } Class<?> c = extensionClasses.get(n); if (c == null) { //放入extensionClasses中去 extensionClasses.put(n, clazz); } else if (c != clazz) { throw new IllegalStateException("Duplicate extension " + type.getName() + " name " + n + " on " + c.getName() + " and " + clazz.getName()); } } } } } } } catch (Throwable t) { IllegalStateException e = new IllegalStateException("Failed to load extension class(interface: " + type + ", class line: " + line + ") in " + url + ", cause: " + t.getMessage(), t); exceptions.put(line, e); } } } // end of while read lines } finally { reader.close(); } } catch (Throwable t) { logger.error("Exception when load extension class(interface: " + type + ", class file: " + url + ") in " + url, t); } } // end of while urls } } catch (Throwable t) { logger.error("Exception when load extension class(interface: " + type + ", description file: " + fileName + ").", t); } }
*/
- 会从目录的type.getName对应的目录中去加载对应的文件。因为本例传入的type是Protocol,所以会查看META-INF/services、META-INF/dubbo/、META-INF/dubbo/internal中的com.alibaba.dubbo.rpc.Protocol文件中的所有的类。
- 如果加载到clazz不是type的子类,则抛异常。
- 如果加载到的类上面加了@Adaptive注解,则将这个类设置成cachedAdaptiveClass。这里需要说明一下:
它的三个实现类:只有AdaptiveExtensionFactory是加了@Adaptive注解的,注意这里最后也是调的loader的getExtension方法(关于这个方法见下文有详细描述)。
这种情况下在上面的getAdaptiveExtensionClass方法中就会走cachedAdaptiveClass != null的分支,直接返回cachedAdaptiveClass, 也就是说生成的ExtensionFactory实例是AdaptiveExtensionFactory。如果没有实现类上加有@Adaptive注解,则会走下面的方法,使用父接口去创建Adaptive,如Protocol接口:
private Class<?> getAdaptiveExtensionClass() { getExtensionClasses();//加载拓展文件中的classes if (cachedAdaptiveClass != null) {//不为null直接返回 return cachedAdaptiveClass; } return cachedAdaptiveClass = createAdaptiveExtensionClass();//创建AdaptiveExtensionClass }
- 如果加载到的类的clazz和之前设置的cachedAdaptiveClass不一致则报错。也就是说一个像Protocol这样的父接口,它的实现类上只能有一个加@Adaptive注解。
- 如果类上面没有加@Adaptive注解,则尝试获取这个类上有无带有父接口(type)类型的参数的构造方法,如果有那么这个实现类就属于使用装饰器模式装饰过的wrappers,放入cachedWrapperClasses中去。如:
- 如果不符合Adaptive和Wrapper的条件,则调用实现类无参的构造方法,同时要求实现类的className要比type的大,如果加了Activate注解则放入cachedActivates目录中(方便com.alibaba.dubbo.common.extension.ExtensionLoader#getActivateExtension(com.alibaba.dubbo.common.URL, java.lang.String)方法调用), 不管有没有Activate注解都放入cachedNames(方便com.alibaba.dubbo.common.extension.ExtensionLoader#getExtensionName(java.lang.Class)方法获取)。
- 加载到的class都会放入到extensionClasses中去。这里有必要先看下getExtension(String name)方法,这个方法是生成的以Protocol为例,Protocol$Adaptive类中的方法获取拓展点的方法:
不同的是对于wrapperClasses这里会使用有参构造的方式进行构建实例。接下来loadExtensionClasses方法是上面分析过的。这就是表明这里是将加载到的classes进行缓存,然后在getExtension方法的时候使用。
- 来看一看createAdaptiveExtensionClass方法的实现
private Class<?> createAdaptiveExtensionClass() { String code = createAdaptiveExtensionClassCode();//要生成的adaptive类的代码 ClassLoader classLoader = findClassLoader();//找类加载器 com.alibaba.dubbo.common.compiler.Compiler compiler = ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.common.compiler.Compiler.class).getAdaptiveExtension(); return compiler.compile(code, classLoader); }
关于Compiler:
com.alibaba.dubbo.common.extension.ExtensionLoader#getDefaultExtension方法:
/** * 返回缺省的扩展,如果没有设置则返回<code>null</code>。 */ public T getDefaultExtension() { getExtensionClasses(); if(null == cachedDefaultName || cachedDefaultName.length() == 0 || "true".equals(cachedDefaultName)) { return null; } return getExtension(cachedDefaultName); }
关于cachedDefaultName:
可见,默认用的是javassist compiler。
对于createAdaptiveExtensionClassCode方法的作用主要是生成adaptive代理类的代码,然后交给Compiler进行编译生成class文件,它的部分代码如下:
- 如果整个类完全没有Adaptive方法,则不需要生成Adaptive类
- 如果有部分Adaptive方法,则对Adaptive方法进行代理,对非Adaptive方法则抛出异常。
- 类的命名规则:public class " + type.getSimpleName() + "$Adpative" + " implements " + type.getCanonicalName() + " ,也就是类(注意是type类,也就是Protocol)的简称+$Adpative,type.getCanonicalName()是父接口签名。
- type.isAssignableFrom(clazz)用于判断是新加载clazz是否是type的子类,不是就报错。
- debug之后得到的Protocol生成的Adaptive代理类代码为:
package com.alibaba.dubbo.rpc;import com.alibaba.dubbo.common.extension.ExtensionLoader;public class Protocol$Adpative implements com.alibaba.dubbo.rpc.Protocol { // 没有打上@Adaptive的方法如果被调到抛异常 public void destroy() { throw new UnsupportedOperationException("method public abstract void com.alibaba.dubbo.rpc.Protocol.destroy() of interface com.alibaba.dubbo.rpc.Protocol is not adaptive method!"); } // 没有打上@Adaptive的方法如果被调到抛异常 public int getDefaultPort() { throw new UnsupportedOperationException("method public abstract int com.alibaba.dubbo.rpc.Protocol.getDefaultPort() of interface com.alibaba.dubbo.rpc.Protocol is not adaptive method!"); } // 接口中export方法打上@Adaptive注册 public com.alibaba.dubbo.rpc.Exporter export(com.alibaba.dubbo.rpc.Invoker arg0) throws com.alibaba.dubbo.rpc.RpcException { if (arg0 == null) throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument == null"); if (arg0.getUrl() == null)//url属性不能为null throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument getUrl() == null"); com.alibaba.dubbo.common.URL url = arg0.getUrl(); String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());//从url中获取到实际使用的拓展点的name,也就是META-INF目录下配置的kv中的key值 if (extName == null)//extentsion的名称不能为null throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.rpc.Protocol) name from url(" + url.toString() + ") use keys([protocol])"); // 利用dubbo服务查找机制根据名称找到具体的扩展点实现 com.alibaba.dubbo.rpc.Protocol extension = (com.alibaba.dubbo.rpc.Protocol) ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.rpc.Protocol.class).getExtension(extName); return extension.export(arg0); } public com.alibaba.dubbo.rpc.Invoker refer(java.lang.Class arg0, com.alibaba.dubbo.common.URL arg1) throws com.alibaba.dubbo.rpc.RpcException { if (arg1 == null) throw new IllegalArgumentException("url == null"); com.alibaba.dubbo.common.URL url = arg1; String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());//从url中获取到实际使用的拓展点的name,也就是META-INF目录下文件中配置的kv中的key值 if (extName == null) throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.rpc.Protocol) name from url(" + url.toString() + ") use keys([protocol])"); //通过extName获取对应的拓展点 com.alibaba.dubbo.rpc.Protocol extension = (com.alibaba.dubbo.rpc.Protocol) ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.rpc.Protocol.class).getExtension(extName); return extension.refer(arg0, arg1); }}
extName是实际需要调用的拓展点的名称,然后可以看到在$Adaptive类中最终是使用getExtension方法来获取真正使用的拓展点的,关于getExtension方法在上面有详细的讲解。
这时候可以回过来看一看com.alibaba.dubbo.config.ServiceConfig中的几个方法,以exportLocal方法为例:
private void exportLocal(URL url) { if (!Constants.LOCAL_PROTOCOL.equalsIgnoreCase(url.getProtocol())) { URL local = URL.valueOf(url.toFullString()) .setProtocol(Constants.LOCAL_PROTOCOL) .setHost(NetUtils.LOCALHOST) .setPort(0); // modified by lishen ServiceClassHolder.getInstance().pushServiceClass(getServiceClass(ref)); Exporter<?> exporter = protocol.export( proxyFactory.getInvoker(ref, (Class) interfaceClass, local)); exporters.add(exporter); logger.info("Export dubbo service " + interfaceClass.getName() +" to local registry"); } }
在构建invoker时url和extName都是事先处理好的,这个不清楚的可以去看我写的另一篇文章:dubbo源码之Proxy、Transporter和Exchanger执行过程。
到这里再回过头来看com.alibaba.dubbo.common.extension.ExtensionLoader#createAdaptiveExtension方法:
private T createAdaptiveExtension() { try { return injectExtension((T) getAdaptiveExtensionClass().newInstance()); } catch (Exception e) { throw new IllegalStateException("Can not create adaptive extenstion " + type + ", cause: " + e.getMessage(), e); }}
上面通过getAdaptiveExtensionClass()获得到了对应的Adaptive的代理的大Class对象,然后通过newInstance方法生成一个实例,再通过injectExtension方法进行注入。
关于injectExtension:
private T injectExtension(T instance) { try { if (objectFactory != null) { for (Method method : instance.getClass().getMethods()) { if (method.getName().startsWith("set") && method.getParameterTypes().length == 1 && Modifier.isPublic(method.getModifiers())) {// 处理所有set方法 Class<?> pt = method.getParameterTypes()[0];// 获取set方法参数类型 try { // 获取setter对应的property名称 String property = method.getName().length() > 3 ? method.getName().substring(3, 4).toLowerCase() + method.getName().substring(4) : ""; Object object = objectFactory.getExtension(pt, property); // 根据类型,名称信息从ExtensionFactory获取,实际上是AdaptiveExtensionFactory if (object != null) { // 如果不为null,说set方法的参数是扩展点类型,那么进行注入 method.invoke(instance, object); } } catch (Exception e) { logger.error("fail to inject via method " + method.getName() + " of interface " + type.getName() + ": " + e.getMessage(), e); } } } } } catch (Exception e) { logger.error(e.getMessage(), e); } return instance;}
该方法的作用类似于spring中的ioc功能,也就是对一个bean 实例进行属性的注入,但是这个属性需要满足一定的条件:有规范的set方法,然后必须是拓展点类型的属性(在extensionFactory中要能找到)。
3. 总结
- 调用示例:ExtensionLoader.getExtensionLoader(Protocol.class).getAdaptiveExtension(),ExtensionLoader.getExtensionLoader(ExtensionFactory.class).getAdaptiveExtension();
- 先是加载META-INF下三个目录中对应的文件com.alibaba.dubbo.rpc.Protocol和com.alibaba.dubbo.common.extension.ExtensionFactory中的每一行定义的实现类的class信息。
- 如果类实现类上如果标注有@Adaptive注解时,则直接采用该实现类生成$Adaptive代理类,如AdaptiveExtensionFactory。如果类上没有@Adaptive注解则先如果类上面没有加@Adaptive注解,则尝试获取这个类上有无带有父接口(type)类型的参数的构造方法, 如果有那么这个实现类就属于使用装饰器模式装饰过的wrappers,放入cachedWrapperClasses中去,wrapper模式的特殊点就是构建实例的时候调用的是有参的构造方法,传入一个拓展点对象。这种没有加@Adaptive注解的,不管符不符合wrapper模式,都会使用接口生成$Adaptive代理类,如Protocol。
- 生成代理类使用的是javaassit技术,代理类会从invoker的url中获取到最终需要调用的拓展点实现类的即(extName,实际调用的拓展点的名称)是调用getExtension去查找对应的拓展点(也就是META-INF目录下文件中配置的kv中的key值)。
- injectExtension的作用相当于ioc,也就是对传入的拓展点实例中还包括拓展点类型的属性时,如果有set方法就可以进行注入。
- 模拟Executor策略的实现如何控制执行顺序?怎么限制最大同时开启线程的个数?为什么要有一个线程来将结束的线程移除出执行区?转移线程的时候要判断线程是否为空遍历线程的容器会抛出ConcurrentM
- ViewPager快速实现引导页
- Linux学习 - 常用和不太常用的实用awk命令
- 漏洞预警:厄运cookie(Misfortune Cookie)漏洞影响全球1200万台路由器
- 漏洞预警:Google安全研究人员发现NTP(网络时间协议)最新漏洞
- 揭秘:从内部源码看Facebook技术(第一集)
- Python 自然语言处理《釜山行》人物关系
- 注意:C++中double的表示是有误差的
- 完善RecyclerView,添加首尾视图
- 初识Node.js
- Linux学习 - SED操作,awk的姊妹篇
- Android面试之高级篇
- 解密所有APP运行过程中的内部逻辑
- RecyclerView数据动态更新
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- vim 后门 | Linux 后门系列
- 个站建设基础教程
- 【Tomcat源码解析】第一章:如何搭建源码阅读环境
- LD_PRELOAD 后门 | Linux 后门系列
- 如何利用k8s拉取私有仓库镜像
- rsyslog queue队列权威指南
- 用LOL获得BUFF场景来看待Guava之事件总线
- 文件&目录小技巧 | Linux后门系列
- 从0到1开发测试平台(十)后端增加登录token返回
- rsyslog磁盘辅助(Disk-Assisted)模式踩坑记
- Guava字符串的处理
- Phishing
- Guava Cache用法介绍(极简版)
- Linux Netcat 命令——网络工具中的瑞士军刀
- __all__ 是干嘛用的?