SPI框架实现之旅三:实现说明

SPI框架实现之旅三:实现说明

前一篇 《SPI框架实现之旅二:整体设计》中,介绍了几个定义的接口,注解;叙述了实现流程;并简单的介绍了 SpiLoader中的部分实现; 本篇则主要介绍SpiLoader类的实现

类图结构如下:

SpiLoader 全解析

spiImpl选择的核心类,包括初始化选择器,初始化spiImpl实现列表,解析spiImpl的选择条件,返回具体的实现类等G

1. 获取spiLoader对象

SpiLoader 是一个泛型对象,每个SPI接口,对应一个SpiLoader<T> 对象,我们提供了一个静态方法来获取这个对象

实现

优先从缓存中获取, 如果缓存没有,则新建一个;缓存中有, 则直接返回

/**
* spiLoader缓存, 其中key为 spi接口, value为对应的Loader对象
*/
private static final ConcurrentMap<Class<?>, SpiLoader<?>> loaderCache = new ConcurrentHashMap<>();


@SuppressWarnings("unchecked")
public static <T> SpiLoader<T> load(Class<T> type) {
   if (null == type) {
       throw new IllegalArgumentException("common cannot be null...");
   }

   if (!type.isInterface()) {
       throw new IllegalArgumentException("common class:" + type + " must be interface!");
   }


   if (!withSpiAnnotation(type)) {
       throw new IllegalArgumentException("common class:" + type + " must have the annotation of @Spi");
   }


   SpiLoader<T> spiLoader = (SpiLoader<T>) loaderCache.get(type);
   if (spiLoader == null) {
       loaderCache.putIfAbsent(type, new SpiLoader<>(type));
       spiLoader = (SpiLoader<T>) loaderCache.get(type);
   }

   return spiLoader;
}

说明

  • 上面有几个校验,前一篇已经说明,不再赘述
  • 上面新建对象,不是线程安全的

2. 新建 SpiLoader对象

创建对象,主要会初始化选择器

实现

private SpiLoader(Class<T> type) {
   // 初始化默认的选择器, 为保留项目, 必然会提供的服务
   selectorInstanceCacheMap.putIfAbsent(DefaultSelector.class, DEFAULT_SELECTOR);

   this.spiInterfaceType = type;
   initSelector();
}


private void initSelector() {
   Spi ano = spiInterfaceType.getAnnotation(Spi.class);
   if (ano == null) {
       currentSelector = initSelector(DefaultSelector.class);
   } else {
       currentSelector = initSelector(ano.selector());
   }


   Method[] methods = this.spiInterfaceType.getMethods();
   currentMethodSelector = new ConcurrentHashMap<>();
   SelectorWrapper temp;
   for (Method method : methods) {
       if (!method.isAnnotationPresent(SpiAdaptive.class)) {
           continue;
       }

       temp = initSelector(method.getAnnotation(SpiAdaptive.class).selector());
       if (temp == null) {
           continue;
       }

       currentMethodSelector.put(method.getName(), temp);
   }
}


private SelectorWrapper initSelector(Class<? extends ISelector> clz) {
   // 优先从选择器缓存中获取类型对应的选择器
   if (selectorInstanceCacheMap.containsKey(clz)) {
       return selectorInstanceCacheMap.get(clz);
   }


   try {
       ISelector selector = clz.newInstance();
       Class paramClz = null;

       Type[] types = clz.getGenericInterfaces();
       for (Type t : types) {
           if (t instanceof ParameterizedType) {
               paramClz = (Class) ((ParameterizedType) t).getActualTypeArguments()[0];
               break;
           }
       }

       Assert.check(paramClz != null);
       SelectorWrapper wrapper = new SelectorWrapper(selector, paramClz);
       selectorInstanceCacheMap.putIfAbsent(clz, wrapper);
       return wrapper;
   } catch (Exception e) {
       throw new IllegalArgumentException("illegal selector defined! yous:" + clz);
   }
}

说明

  • 持有一个选择器缓存列表,selectorInstanceCacheMap
    • 保证每种类型的选择器,在这个SpiLoader中,只会有一个实例存在
    • 不做成全局唯一的原因是尽量隔离, 比如 ParamsSelector 内部缓存了spi实现的列表,如果全局公用的话,就会混掉,导致这个列表中就出现非这个spi接口的实现类
  • 类选择器 + 方法选择器
    • currentSelector : 类选择器, 解析 @Spi 注解获取,适用于静态选择 + 动态选择两种使用方式
    • currentMethodSelector : 方法选择器,解析 @SpiAdaptive 注解获取, 仅适用于动态选择SPI实现的方式
    • 优先级: 方法上定义的选择器 由于 类上定义的选择器; 方法上未定义时,默认使用类定义的选择器

3. 静态使用

静态使用方式,表示根据传入的条件,选择一个满足条件的实现返回

实现

/**
* 根据传入条件, 选择具体的spi实现类
* <p/>
* 这里要求conf的类型和选择器的参数类型匹配, 否则会尝试使用默认的选择器补救, 若补救失败, 则抛异常
*
* @param conf
* @return
* @throws NoSpiMatchException
* @throws IllegalArgumentException
*/
@SuppressWarnings("unchecked")
public T getService(Object conf) throws NoSpiMatchException {
   if (spiImplClassCacheMap == null || spiImplClassCacheMap.size() == 0) {
       loadSpiService();
   }

   if (!currentSelector.getConditionType().isAssignableFrom(conf.getClass())) {

       /**
        * 参数类型不匹配时, 判断是否可以根据默认的选择器来获取
        */
       if (conf instanceof String) {
           return (T) DEFAULT_SELECTOR.getSelector().selector(spiImplClassCacheMap, conf);
       }


       /**
        * 参数类型完全不匹配, 则抛参数异常
        */
       throw new IllegalArgumentException("conf spiInterfaceType should be sub class of [" + currentSelector.getConditionType() + "] but yours:" + conf.getClass());
   }


   return (T) currentSelector.getSelector().selector(spiImplClassCacheMap, conf);
}

说明

  1. spiImplClassCacheMap spi实现的缓存映射表,优先判断缓存映射表是否存在,不存在时需要初始化;存在时,则进入校验逻辑
  2. 校验 校验传入的参数,是否匹配当前的选择器参数类型,为了保证选择器可以正常运行 当不匹配时,会有一个兼容逻辑,判断传参类型是否为String, 是则采用默认的选择器,根据name来选择spi实现 (这种实现可能造成选择的实现不是预期的)
  3. 静态使用方式,使用类定义选择器 : currentSelector
    • 静态使用的方式,目标就是事前就确认使用这个实现了,不会出现变动了; 相当于一次确认,所有的调用都是确认的
    • 静态使用,方法注解的选择器无效。这个我们从逆向的思路进行解释 IPrint 是一个Spi接口, 有两个实现 FilePrint, ConsolePrint 假设 `currentSelector=DefaultSelector`, 方法 methodA 上定义的是 ParamsSelector 时 静态使用方式,获取一个spi实现,希望在所有的spi接口使用处,都输出到文件,用户根据 `FilePrint` 选择 FilePrint 这个类来执行具体的输出逻辑, 如果在调用 methodA 方法执行时, 假设根据 ParamsSelector 判断, ConsolePrint 才满足这儿条件,这是相当于在具体实现时,换成了另一个 ConsolePrint, 这下子就与我们的初衷背离了(如果目标是想实现这个场景,显然动态适配的方式才是正确的使用姿势)
  4. loadService 的逻辑后面详细说明

4. 动态使用

动态使用区别于静态的直接确定实现类, 通过getService 获取的并不是某个特定对的实现类,而是一个动态生成的代理,每次具体执行之前,会去判断一下,应该选择哪一个实现来执行

设计的出发点

可以考虑下,我们的目标是在执行方法之前,需要判断一下哪个实现类满足要求,选择这个实现类来执行这个方法,那么我们可以怎么去做?

考虑到切面的方式,如果有一种手段,在方法执行之前,织入一段业务逻辑,就可以达到上面的目的

最开始虽然是怎么想的,但是有点尴尬的是,不知道怎么去实现;因此换了一个思路,我自己新生成一个接口的实现类,在这个实现类里面做选择逻辑,然后把这个实现类对象返回

实现如下

和静态实现的逻辑差不多,一般流程如下:

  • 判断spi实现类的映射关系表是否初始化,若没有则初始化
  • 获取选择器
    • 优先从方法选择器中查找, 若存在,则直接选中;
    • 不存在,则使用类选择器
  • 校验:判断传入条件参数类型是否满足选择器的参数类型匹配(将方法的第一个参数,作为选择器的选择条件)
  • 返回实现类
@SuppressWarnings("unchecked")
public T getService(Object conf, String methodName) throws NoSpiMatchException {
   if (spiImplClassCacheMap == null || spiImplClassCacheMap.size() == 0) {
       loadSpiService();
   }


   // 首先获取对应的selector
   SelectorWrapper selector = currentMethodSelector.get(methodName);
   if (selector == null) { // 自适应方法上未定义选择器, 则默认继承类的
       selector = currentSelector;
       currentMethodSelector.putIfAbsent(methodName, selector);
   }

   if (!selector.getConditionType().isAssignableFrom(conf.getClass())) { // 选择器类型校验
       if (!(conf instanceof String)) {
           throw new IllegalArgumentException("conf spiInterfaceType should be sub class of [" + currentSelector.getConditionType() + "] but yours:" + conf.getClass());
       }


       selector = DEFAULT_SELECTOR;
   }


   if (spiImplMethodCacheMap.size() == 0) {
       return (T) selector.getSelector().selector(spiImplClassCacheMap, conf);
   }


   try {
       // 采用默认的选择器,根据指定name 进行查询时, 需要兼容一下, 因为method对应的缓存key为  SpiImpName_methodName
       if (DEFAULT_SELECTOR.equals(selector)) {
           if (spiImplMethodCacheMap.containsKey(conf)) {
               return (T) selector.getSelector().selector(spiImplMethodCacheMap, conf);
           }


           if (spiImplClassCacheMap.containsKey(conf)) {
               return (T) selector.getSelector().selector(spiImplClassCacheMap, conf);
           }


           return (T) selector.getSelector().selector(spiImplMethodCacheMap, conf + "_" + methodName);
       } else {
           return (T) selector.getSelector().selector(spiImplMethodCacheMap, conf);
       }
   } catch (Exception e) {
       return (T) selector.getSelector().selector(spiImplClassCacheMap, conf);
   }
}

说明

  1. 这个方法通常是由框架生成的代理实现类来调用(后面会说明动态生成代理类的逻辑)
  2. 区别与静态使用方式, 优先根据方法名,查找对应的选择器;当未定义时,使用类选择器
  3. 默认选择器,根据name来查询实现时,传入的参数特殊处理下,主要是因为 spiImplMethodCacheMap 中key的生成,有一个小转换 若实现类上没有 @SpiConf注解,或者 @SpiConf的注解没有定义 name 属性,则类的唯一标识name为:简单类名; 否则为指定的name属性 若方法上显示使用 @SpiConf 指定了name属性,则key的生成规则为: 方法注解上指定的name; 如果没有 @SpiConf注解,或其中没有指定name属性,则key生成规则: 类name属性 + 下划线 + 方法名 这一点单独看可能不太好理解,因此可以和下面的spi实现类映射关系的初始化结合起来
  4. 动态生成代理类的逻辑,放在最后进行说明

5. spi实现类映射关系表初始化

为了避免每次选择具体的实现类时,都去加载一遍,耗时耗力好性能,因此加一个缓存是很有必要的,这里主要说下这个实现逻辑,以及为啥这么干

缓存结构

使用了两个Map:

  • 一个是类级别的映射关系 spiImplClassCacheMap
    • 静态使用时,只会用搞这个
    • 动态适配时,当下面的映射关系中无法获取满足条件的实现时,会再次从这里进行判断
    • key: @SpiConf 注解中定义的name; 或者spi实现类的简单类名
  • 一个是方法的映射关系 spiImplMethodCacheMap
    • 动态适配时, 选择器优先从这里进行判断
    • key: @SpiConf 注解中定义的name; 或者是 实现类的 name + "_" + 方法名
/**
* name : spiImpl 的映射表
*/
private Map<String, SpiImplWrapper<T>> spiImplClassCacheMap;


/**
* 自适应时, 根据方法选择实现; name : spiImpl 的映射表
*/
private Map<String, SpiImplWrapper<T>> spiImplMethodCacheMap;

实现

private void loadSpiService() {
   List<SpiImplWrapper<T>> spiServiceList = new ArrayList<>();
   List<SpiImplWrapper<T>> spiServiceMethodList = new ArrayList<>();

   ServiceLoader<T> serviceLoader = ServiceLoader.load(spiInterfaceType);

   SpiConf spiConf;
   String implName;
   int implOrder;
   for (T t : serviceLoader) {
       spiConf = t.getClass().getAnnotation(SpiConf.class);
       Map<String, String> map;
       if (spiConf == null) {
           implName = t.getClass().getSimpleName();
           implOrder = SpiImplWrapper.DEFAULT_ORDER;


           // 参数选择器时, 要求spi实现类必须有 @SpiConf 注解, 否则选择器无法获取校验条件参数
           if (currentSelector.getSelector() instanceof ParamsSelector) {
               throw new IllegalStateException("spiImpl must contain annotation @SpiConf!");
           }

           map = Collections.emptyMap();
       } else {
           implName = spiConf.name();
           if (StringUtils.isBlank(implName)) {
               implName = t.getClass().getSimpleName();
           }

           implOrder = spiConf.order() < 0 ? SpiImplWrapper.DEFAULT_ORDER : spiConf.order();

           map = parseParms(spiConf.params());
       }

       // 添加一个类级别的封装类
       spiServiceList.add(new SpiImplWrapper<>(t, implOrder, implName, map));


       // todo 改成 getMethods(), 但是过滤掉 Object类中的基础方法
       Method[] methods = t.getClass().getDeclaredMethods();
       String methodImplName;
       int methodImplOrder;
       Map<String, String> methodParams;
       for (Method method : methods) {
           spiConf = method.getAnnotation(SpiConf.class);
           if (spiConf == null) {
               continue;
           }


           // 方法上有自定义注解, 且定义的name与类实现名不同, 则直接采用
           // 否则采用  ServiceName_MethodName 方式定义
           if (StringUtils.isBlank(spiConf.name()) || implName.equals(spiConf.name())) {
               methodImplName = implName + "_" + method.getName();
           } else {
               methodImplName = spiConf.name();
           }


           // 优先级, 以最小的为准 (即一个类上的优先级很低, 也可以定义优先级高的方法)
           // 方法注解未定义顺序时, 继承类上的顺序
           methodImplOrder = Math.min(implOrder, spiConf.order() < 0 ? implOrder : spiConf.order());


           // 自适应方法的参数限制, 要求继承类上的参数
           methodParams = parseParms(spiConf.params());
           if (map.size() > 0) { // 方法的参数限定会继承类上的参数限定
               if (methodParams.size() == 0) {
                   methodParams = map;
               } else {
                   methodParams.putAll(map);
               }
           }


           spiServiceMethodList.add(new SpiImplWrapper<>(t, methodImplOrder, methodImplName, methodParams));
       }
   }


   if (spiServiceList.size() == 0) {
       throw new IllegalStateException("no spiImpl implements spi: " + spiInterfaceType);
   }


   this.spiImplClassCacheMap = initSpiImplMap(spiServiceList);
   this.spiImplMethodCacheMap = initSpiImplMap(spiServiceMethodList);
}

private Map<String, SpiImplWrapper<T>> initSpiImplMap(List<SpiImplWrapper<T>> list) {
   // 映射为map, 限定不能重名
   Map<String, SpiImplWrapper<T>> tempMap = new ConcurrentHashMap<>();
   for (SpiImplWrapper<T> wrapper : list) {
       if (tempMap.containsKey(wrapper.getName())) {
           throw new IllegalArgumentException("duplicate spiImpl name " + wrapper.getName());
       }

       tempMap.put(wrapper.getName(), wrapper);
   }
   return tempMap;
}

上面的逻辑可以分为两块,一块是上半边的初始化,获取spiImplClassCacheMap; 下一块则是扫描实现类的所有方法,将方法上标有@SpiConf注解的捞出来,用于初始化 spiImplMethodCacheMap

说明

  1. 缓存结构中value为 SpiImplWrapper
    • 缓存value并不是简单的实现类,封装类的定义如下,将条件和排序也同时封装进去了 private T spiImpl; private int order; /** * spiImpl 的标识name, 要求唯一 * <p/> * {@link com.hust.hui.quicksilver.spi.selector.DefaultSelector 选择具体的SpiImpl 时使用} */ private String name; /** * 参数校验规则 * <p/> * {@link com.hust.hui.quicksilver.spi.selector.ParamsSelector} 选择具体的SpiImpl 时使用 * 要求每个实现类都有注解 {@link SpiConf} */ private Map<String, String> paramCondition;
    • name 的定义,类与方法两个纬度的缓存中,定义规则不同,具体可以看《缓存结构》这里的说明
  2. 采用 ParamsSelector 时, 要求 @SpiConf 注解必须存在
  3. 注意扫描所有方法对应的注解, spi实现类,如果存在继承则会出现问题 // todo 改成 getMethods(), 但是过滤掉 Object类中的基础方法 Method[] methods = t.getClass().getDeclaredMethods();

动态代码生成

上面在谈论动态使用的时候,采用的方案是,生成一个代理类,实现spi接口, 在具体的实现逻辑中,使用选择器来获取满足条件的实现类,然后执行相应的方法

1. 代理类格式

采用倒推方式,先给出一个实际的代理类如下,具体的实现中其实只有两行代码

  1. 获取具体的实现类 (调用上面的 SpiLoader.getService(conf, methodName
  2. 执行实现类的接口
package com.hust.hui.quicksilver.spi.test.print;

import com.hust.hui.quicksilver.spi.SpiLoader;

public class IPrint$Proxy implements com.hust.hui.quicksilver.spi.test.print.IPrint {

    public void print(java.lang.String arg0) {
        try {
            com.hust.hui.quicksilver.spi.test.print.IPrint spiImpl = SpiLoader.load(com.hust.hui.quicksilver.spi.test.print.IPrint.class).getService(arg0, "print");
            spiImpl.print(arg0);
        } catch (com.hust.hui.quicksilver.spi.exception.NoSpiMatchException e) {
            throw new java.lang.RuntimeException(e);
        }
    }

    public void adaptivePrint(java.lang.String arg0, java.lang.String arg1) {
        try {
            com.hust.hui.quicksilver.spi.test.print.IPrint spiImpl = SpiLoader.load(com.hust.hui.quicksilver.spi.test.print.IPrint.class).getService(arg0, "adaptivePrint");
            spiImpl.adaptivePrint(arg0, arg1);
        } catch (com.hust.hui.quicksilver.spi.exception.NoSpiMatchException e) {
            throw new java.lang.RuntimeException(e);
        }
    }
}

上面给出了一个代理类的演示,那么剩下两个问题,一个是如何生成代理类; 一个是如何运行代理类(上面是java代码,我们知道运行得是字节码才行)

代理类生成

对着上面的实现,反推代码生成,其实比较简单了,无非就是生成一大串的String罢了,这里真没什么特殊的,贴下实现,逻辑省略

    /**
     * 构建SPI接口的实现代理类, 在执行动态适配的方法时, 调用SpiLoader的 spiImpl选择器, 选择具体的实现类执行
     *
     * @return
     */
    public static String buildTempImpl(Class type) {
        StringBuilder codeBuilder = new StringBuilder();

        codeBuilder.append("package ").append(type.getPackage().getName()).append(";");
        codeBuilder.append("\nimport ").append(SpiLoader.class.getName()).append(";");
        codeBuilder.append("\npublic class ").append(type.getSimpleName()).append("$Proxy implements ").append(type.getCanonicalName()).append(" {\n");


        Method[] methods = type.getMethods();
        for (Method method : methods) {
            Class<?> returnType = method.getReturnType(); //函数返回值
            Class<?>[] parameterTypes = method.getParameterTypes();//函数参数列表
            Class<?>[] exceptionTypes = method.getExceptionTypes();//函数异常列表


            // build method code
            StringBuilder code = new StringBuilder(512);
            if (parameterTypes.length < 0) {   //检查该函数参数列表中,第一个参数作为选择器参数
                code.append("throw new IllegalArgumentException(\"there should be one argument for selector to choose spiImpl\")");
            } else { // 没有 SpiAdaptive注解的, 采用默认的注解方式
                code.append("try{\n");
                code.append(type.getName()).append(" spiImpl=")
                        .append("SpiLoader.load(")
                        .append(type.getName()).append(".class")
                        .append(").getService(arg0,\"")
                        .append(method.getName())
                        .append("\");");

                if (!"void".equals(returnType.getName())) {
                    code.append("return ");
                }
                code.append("spiImpl.").append(method.getName()).append("(arg0");
                for (int i = 1; i < parameterTypes.length; i++) {
                    code.append(",").append("arg").append(i);
                }
                code.append(");");
                code.append("\n} catch(com.hust.hui.quicksilver.spi.exception.NoSpiMatchException e){\nthrow new java.lang.RuntimeException(e);\n}");
            }


            // build method signature
            codeBuilder.append("\npublic ").append(returnType.getName()).append(" ").append(method.getName())
                    .append("(").append(parameterTypes[0].getName()).append(" arg0");

            for (int i = 1; i < parameterTypes.length; i++) {
                codeBuilder.append(", ").append(parameterTypes[i].getName()).append(" arg").append(i);
            }
            codeBuilder.append(") ");
            if (exceptionTypes.length > 0) {
                codeBuilder.append("throw ").append(exceptionTypes[0].getName());
                for (int i = 1; i < exceptionTypes.length; i++) {
                    codeBuilder.append(", ").append(exceptionTypes[i].getName());
                }
            }
            codeBuilder.append("{\n");
            codeBuilder.append(code.toString()).append("\n}");
        }

        codeBuilder.append("\n}");
        return codeBuilder.toString();
    }

动态编译运行

动态编译,最开始想的是利用jdk的动态编译方式,试来试去没搞成功,然后选择了一个折中的方案,把代理类看成是groovy代码,利用 GroovyEngine 来实现动态运行, 这一块的逻辑也超级简单,下面的短短几行代码即可; 后面有空单独研究下java的动态编译

@SuppressWarnings("unchecked")
public static <T> T compile(String code, Class<T> interfaceType, ClassLoader classLoader) throws SpiProxyCompileException {
   GroovyClassLoader loader = new GroovyClassLoader(classLoader);
   Class clz = loader.parseClass(code);

   if (!interfaceType.isAssignableFrom(clz)) {
       throw new IllegalStateException("illegal proxy type!");
   }


   try {
       return (T) clz.newInstance();
   } catch (Exception e) {
       throw new SpiProxyCompileException("init spiProxy error! msg: " + e.getMessage());
   }
}

小结

至此,核心的东西基本上都过了一遍,主要的设计思路,实现逻辑,执行流程都说完了,目前的实现中,仍有一些缺陷,欢迎提出问题和建议,无比感激

其他

博客系列链接:

  • SPI框架实现之旅一:背景介绍
  • SPI框架实现之旅二:整体设计
  • SPI框架实现之旅三:实现说明
  • SPI框架实现之旅四:使用测试

源码地址:

https://git.oschina.net/liuyueyi/quicksilver/tree/master/silver-spi

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏王磊的博客

vuejs深入浅出—基础篇

一、从HelloWorld说起 任何语言的都是从Hello World开始的,VueJs也不例外,直接上代码: <script src="https://unp...

3146
来自专栏程序员互动联盟

【编程基础】聊聊C语言-兵马未动粮草先行(1)

上一篇我们讲的聊聊C语言-我的地盘我做主,相信大家对变量的存储类型和变量的作用域有了一定的了解。现在我们马上公布上期的答案如下: #include<stdio....

3318
来自专栏上善若水

002-golang- 命令行参数处理及flag包

1413
来自专栏前端杂货铺

前端开发中的字符编码

前端开发过程中会接触各种各样的编码,比较常见的主要是UTF-8和HTML实体编码,但是web前端的世界却不止这两种编码,而且编码的选择也会造成一定的问题,如前后...

3288
来自专栏绿巨人专栏

TypeScript中的怪语法

1153
来自专栏小灰灰

SPI框架实现之旅二:整体设计

SPI框架实现之旅二:整体设计 上一篇简单的说了一下spi相关的东西, 接下来我们准备开动,本篇博文主要集中在一些术语,使用规范的约定和使用方式 设计思路 下...

3098
来自专栏Crossin的编程教室

【编程课堂】文本包装 textwrap

欢迎回到编程课堂,本周我们将了解一个与文字处理相关的模块—— textwrap,依照惯例,此处应该有一思考题: 现在有一文本文件 ‘abc.txt’,有 100...

34010
来自专栏小狼的世界

如何向回调函数中传入其他参数

最近写JS经常会因为向回调函数中传参而头疼,今天总结一下向回调函数中传参的方法,以后的应用中就不用在到处去找了。

641
来自专栏Golang语言社区

JS基础(下)

事件DOM Event 更多事件请查询h3c htmldom参考手册 event onclick() :当点击时 onfocus() :获得焦点时 ;当...

4297
来自专栏听雨堂

获得定长字符串

        C#中的字符串是Unicode编码,length是Unicode的Char的个数。所以,假如一个字符串中中英文混杂,又想获得一个固定宽度的字符串...

2366

扫码关注云+社区

领取腾讯云代金券