• 基于Annotation与SpringAOP的缓存简单解决方案


    前言:

      由于项目的原因,需要对项目中大量访问多修改少的数据进行缓存并管理,为达到开发过程中通过Annotation简单的配置既可以完成对缓存的设置与更新的需求,故而设计的该简易的解决方案。

    涉及技术:

    1.Spring AOP

    2.Java Annotation

    3.Memcache (项目中使用的缓存组件)

    4.JVM基础 (Class文件结构,用于解析出方法中的形参名称,动态生成缓存key,目测效率不高0.0)

    5.Ognl (用于动态解析缓存的key)

    实现细节:

    Annotation:LoadFromMemcached 用于method之上的注解,作用是使带有该注解的method在调用的时候先经过缓存查询,缓存中查询不到再去数据库查询并将结果缓存至缓存服务器Memcache中,

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface LoadFromMemcached {
       String value();//缓存的key     
       int timeScope() default 600;//默认过期时间,单位秒
       String condition() default "";//执行缓存查询的条件
    }
    

     Annotation:UpdateForMemcached 类似于LoadFromMemcached,作用是使带有该注解的method在调用的时候更新缓存服务器中的缓存,

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface UpdateForMemcached {
      String[] value();//可能有多个key需要更新
      String condition() default "";//执行缓存的条件
    }
    

      AOP:MemcachedCacheInterceptor 缓存AOP实现的核心类,用于对Annotation注解了的method进行拦截并进行相应的操作,

      1 import java.lang.annotation.Annotation;
      2 import java.lang.reflect.Method;
      3 import java.util.ArrayList;
      4 import java.util.HashMap;
      5 import java.util.List;
      6 import java.util.Map;
      7 import java.util.concurrent.TimeoutException;
      8 import java.util.regex.Matcher;
      9 import java.util.regex.Pattern;
     10 import javax.annotation.Resource;
     11 import net.rubyeye.xmemcached.MemcachedClient;
     12 import net.rubyeye.xmemcached.exception.MemcachedException;
     13 import ognl.Ognl;
     14 import ognl.OgnlException;
     15 import org.aspectj.lang.ProceedingJoinPoint;
     16 import org.aspectj.lang.annotation.Around;
     17 import org.aspectj.lang.annotation.Aspect;
     18 import org.aspectj.lang.reflect.MethodSignature;
     19 import org.slf4j.Logger;
     20 import org.slf4j.LoggerFactory;
     21 import org.springframework.stereotype.Component;
     22 @Component
     23 @Aspect
     24 public class MemcachedCacheInterceptor {
     25   private final String GET = "@annotation(LoadFromMemcached)";
     26   private final String UPDATE = "@annotation(UpdateForMemcached)";
     27   // 替换为其他缓存组件即可切换为其他缓存系统,这里是使用的Memcached。如果再抽象一层缓存系统管理,则可以动态的更换缓存系统。
     28   @Resource
     29   private MemcachedClient cache;
     30   private Logger log = LoggerFactory
     31       .getLogger(MemcachedCacheInterceptor.class);
     32   /**
     33    * 
     34    * @Title: get
     35    * @Description: 首先从缓存中加载数据,缓存命中则返回数据,未命中则从数据库查找,并加入缓存
     36    * @param @param call
     37    * @param @return
     38    * @param @throws Throwable
     39    * @return Object
     40    * @throws
     41    */
     42   @Around(GET)
     43   public Object get(ProceedingJoinPoint call) throws Throwable {
     44     LoadFromMemcached anno = getAnnotation(call, LoadFromMemcached.class);
     45     String key = anno.value();
     46     int timeSocpe = anno.timeScope();
     47     if (!executeCondition(anno.condition(), call)) {// 不满足条件,直接调用方法,不进行缓存AOP操作
     48       return call.proceed();
     49     }
     50     key = getKeyNameFromParam(key, call);
     51     Object value = null;
     52     try {
     53       value = cache.get(key);
     54     } catch (TimeoutException e) {
     55       log.error("Get Data From Memcached TimeOut!About Key:" + key, e);
     56       e.printStackTrace();
     57     } catch (InterruptedException e) {
     58       log.error(
     59           "Get Data From Memcached TimeOut And Interrupted!About Key:"
     60               + key, e);
     61       e.printStackTrace();
     62     } catch (MemcachedException e) {
     63       log.error(
     64           "Get Data From Memcached And Happend A Unexpected Error!About Key:"
     65               + key, e);
     66       e.printStackTrace();
     67     }
     68     if (value == null) {
     69       value = call.proceed();
     70       if (value != null) {
     71         try {
     72           cache.add(key, timeSocpe, value);
     73           log.info("Add Data For Memcached Success!About Key:" + key);
     74         } catch (TimeoutException e) {
     75           log.error(
     76               "Add Data For Memcached TimeOut!About Key:" + key,
     77               e);
     78           e.printStackTrace();
     79         } catch (InterruptedException e) {
     80           log.error(
     81               "Add Data For Memcached TimeOut And Interrupted!About Key:"
     82                   + key, e);
     83           e.printStackTrace();
     84         } catch (MemcachedException e) {
     85           log.error(
     86               "Add Data For Memcached And Happend A Unexpected Error!About Key:"
     87                   + key, e);
     88           e.printStackTrace();
     89         }
     90       }
     91     }
     92     return value;
     93   }
     94   /**
     95    * 
     96    * @Title: update
     97    * @Description: 执行方法的同时更新缓存中的数据
     98    * @param @param call
     99    * @param @return
    100    * @param @throws Throwable
    101    * @return Object
    102    * @throws
    103    */
    104   @Around(UPDATE)
    105   public Object update(ProceedingJoinPoint call) throws Throwable {
    106     UpdateForMemcached anno = getAnnotation(call, UpdateForMemcached.class);
    107     String[] key = anno.value();// 可能需要更新多个key
    108     Object value = call.proceed();
    109     if (!executeCondition(anno.condition(), call)) {// 不满足条件,直接调用方法,不进行缓存AOP操作
    110       return value;
    111     }
    112     if (value != null) {
    113       try {
    114         for (String singleKey : key) {// 循环处理所有需要更新的key
    115           String tempKey = getKeyNameFromParam(singleKey, call);
    116           cache.delete(tempKey);
    117         }
    118         log.info("Update Data For Memcached Success!About Key:" + key);
    119       } catch (TimeoutException e) {
    120         log.error("Update Data For Memcached TimeOut!About Key:" + key,
    121             e);
    122         e.printStackTrace();
    123       } catch (InterruptedException e) {
    124         log.error(
    125             "Update Data For Memcached TimeOut And Interrupted!About Key:"
    126                 + key, e);
    127         e.printStackTrace();
    128       } catch (MemcachedException e) {
    129         log.error(
    130             "Update Data For Memcached And Happend A Unexpected Error!About Key:"
    131                 + key, e);
    132         e.printStackTrace();
    133       }
    134     }
    135     return value;
    136   }
    137   /**
    138    * 
    139    * @Title: getAnnotation
    140    * @Description: 获得Annotation对象
    141    * @param @param <T>
    142    * @param @param jp
    143    * @param @param clazz
    144    * @param @return
    145    * @return T
    146    * @throws
    147    */
    148   private <T extends Annotation> T getAnnotation(ProceedingJoinPoint jp,
    149       Class<T> clazz) {
    150     MethodSignature joinPointObject = (MethodSignature) jp.getSignature();
    151     Method method = joinPointObject.getMethod();
    152     return method.getAnnotation(clazz);
    153   }
    154   /**
    155    * 
    156    * @Title: getKeyNameFromParam
    157    * @Description: 获得组合后的KEY值
    158    * @param @param key
    159    * @param @param jp
    160    * @param @return
    161    * @return String
    162    * @throws
    163    */
    164   private String getKeyNameFromParam(String key, ProceedingJoinPoint jp) {
    165     if (!key.contains("$")) {
    166       return key;
    167     }
    168     String regexp = "\$\{[^\}]+\}";
    169     Pattern pattern = Pattern.compile(regexp);
    170     Matcher matcher = pattern.matcher(key);
    171     List<String> names = new ArrayList<String>();
    172     try {
    173       while (matcher.find()) {
    174         names.add(matcher.group());
    175       }
    176       key = executeNames(key, names, jp);
    177     } catch (Exception e) {
    178       log.error("Regex Parse Error!", e);
    179     }
    180     return key;
    181   }
    182   /**
    183    * 
    184    * @Title: executeNames
    185    * @Description: 对KEY中的参数进行替换
    186    * @param @param key
    187    * @param @param names
    188    * @param @param jp
    189    * @param @return
    190    * @param @throws OgnlException
    191    * @return String
    192    * @throws
    193    */
    194   private String executeNames(String key, List<String> names,
    195       ProceedingJoinPoint jp) throws OgnlException {
    196     Method method = ((MethodSignature) jp.getSignature()).getMethod();
    197     // 形参列表
    198     List<String> param = MethodParamNamesScaner.getParamNames(method);
    199     if (names == null || names.size() == 0) {
    200       return key;
    201     }
    202     Object[] params = jp.getArgs();
    203     Map<String, Object> map = new HashMap<String, Object>();
    204     for (int i = 0; i < param.size(); i++) {
    205       map.put(param.get(i), params[i]);
    206     }
    207     for (String name : names) {
    208       String temp = name.substring(2);
    209       temp = temp.substring(0, temp.length() - 1);
    210       key = myReplace(key, name, (String) Ognl.getValue(temp, map));
    211     }
    212     return key;
    213   }
    214   /**
    215    * 
    216    * @Title: myReplace
    217    * @Description: 不依赖Regex的替换,避免$符号、{}等在String.replaceAll方法中当做Regex处理时候的问题。
    218    * @param @param src
    219    * @param @param from
    220    * @param @param to
    221    * @param @return
    222    * @return String
    223    * @throws
    224    */
    225   private String myReplace(String src, String from, String to) {
    226     int index = src.indexOf(from);
    227     if (index == -1) {
    228       return src;
    229     }
    230     return src.substring(0, index) + to
    231         + src.substring(index + from.length());
    232   }
    233   /**
    234    * 
    235    * @Title: executeCondition
    236    * @Description: 判断是否需要进行缓存操作
    237    * @param @param condition parm
    238    * @param @return
    239    * @return boolean true:需要 false:不需要
    240    * @throws
    241    */
    242   private boolean executeCondition(String condition, ProceedingJoinPoint jp) {
    243     if ("".equals(condition)) {
    244       return true;
    245     }
    246     Method method = ((MethodSignature) jp.getSignature()).getMethod();
    247     // 形参列表
    248     List<String> param = MethodParamNamesScaner.getParamNames(method);
    249     if (param == null || param.size() == 0) {
    250       return true;
    251     }
    252     Object[] params = jp.getArgs();
    253     Map<String, Object> map = new HashMap<String, Object>();
    254     for (int i = 0; i < param.size(); i++) {
    255       map.put(param.get(i), params[i]);
    256     }
    257     boolean returnVal = false;
    258     try {
    259       returnVal = (Boolean) Ognl.getValue(condition, map);
    260     } catch (OgnlException e) {
    261       e.printStackTrace();
    262     }
    263     return returnVal;
    264   }
    265   public void setCache(MemcachedClient cache) {
    266     this.cache = cache;
    267   }
    268 }
    辅助类:借用MethodParamNamesScaner类与Ognl结合完成对缓存key的动态解析功能,  
      1 //引用至:https://gist.github.com/wendal/2011728,用于解析方法的形参名称
      2 import java.io.BufferedInputStream;
      3 import java.io.DataInputStream;
      4 import java.io.IOException;
      5 import java.io.InputStream;
      6 import java.lang.reflect.Constructor;
      7 import java.lang.reflect.Method;
      8 import java.util.ArrayList;
      9 import java.util.HashMap;
     10 import java.util.List;
     11 import java.util.Map;
     12 /**
     13  * 通过读取Class文件,获得方法形参名称列表
     14  * 
     15  * @author wendal(wendal1985@gmail.com)
     16  * 
     17  */
     18 public class MethodParamNamesScaner {
     19   /**
     20    * 获取Method的形参名称列表
     21    * 
     22    * @param method
     23    *            需要解析的方法
     24    * @return 形参名称列表,如果没有调试信息,将返回null
     25    */
     26   public static List<String> getParamNames(Method method) {
     27     try {
     28       int size = method.getParameterTypes().length;
     29       if (size == 0)
     30         return new ArrayList<String>(0);
     31       List<String> list = getParamNames(method.getDeclaringClass()).get(
     32           getKey(method));
     33       if (list != null && list.size() != size)
     34         return list.subList(0, size);
     35       return list;
     36     } catch (Throwable e) {
     37       throw new RuntimeException(e);
     38     }
     39   }
     40   /**
     41    * 获取Constructor的形参名称列表
     42    * 
     43    * @param constructor
     44    *            需要解析的构造函数
     45    * @return 形参名称列表,如果没有调试信息,将返回null
     46    */
     47   public static List<String> getParamNames(Constructor<?> constructor) {
     48     try {
     49       int size = constructor.getParameterTypes().length;
     50       if (size == 0)
     51         return new ArrayList<String>(0);
     52       List<String> list = getParamNames(constructor.getDeclaringClass())
     53           .get(getKey(constructor));
     54       if (list != null && list.size() != size)
     55         return list.subList(0, size);
     56       return list;
     57     } catch (Throwable e) {
     58       throw new RuntimeException(e);
     59     }
     60   }
     61   // ---------------------------------------------------------------------------------------------------
     62   /**
     63    * 获取一个类的所有方法/构造方法的形参名称Map
     64    * 
     65    * @param klass
     66    *            需要解析的类
     67    * @return 所有方法/构造方法的形参名称Map
     68    * @throws IOException
     69    *             如果有任何IO异常,不应该有,如果是本地文件,那100%遇到bug了
     70    */
     71   public static Map<String, List<String>> getParamNames(Class<?> klass)
     72       throws IOException {
     73     InputStream in = klass.getResourceAsStream("/"
     74         + klass.getName().replace('.', '/') + ".class");
     75     return getParamNames(in);
     76   }
     77   public static Map<String, List<String>> getParamNames(InputStream in)
     78       throws IOException {
     79     DataInputStream dis = new DataInputStream(new BufferedInputStream(in));
     80     Map<String, List<String>> names = new HashMap<String, List<String>>();
     81     Map<Integer, String> strs = new HashMap<Integer, String>();
     82     dis.skipBytes(4);// Magic
     83     dis.skipBytes(2);// 副版本号
     84     dis.skipBytes(2);// 主版本号
     85     // 读取常量池
     86     int constant_pool_count = dis.readUnsignedShort();
     87     for (int i = 0; i < (constant_pool_count - 1); i++) {
     88       byte flag = dis.readByte();
     89       switch (flag) {
     90       case 7:// CONSTANT_Class:
     91         dis.skipBytes(2);
     92         break;
     93       case 9:// CONSTANT_Fieldref:
     94       case 10:// CONSTANT_Methodref:
     95       case 11:// CONSTANT_InterfaceMethodref:
     96         dis.skipBytes(2);
     97         dis.skipBytes(2);
     98         break;
     99       case 8:// CONSTANT_String:
    100         dis.skipBytes(2);
    101         break;
    102       case 3:// CONSTANT_Integer:
    103       case 4:// CONSTANT_Float:
    104         dis.skipBytes(4);
    105         break;
    106       case 5:// CONSTANT_Long:
    107       case 6:// CONSTANT_Double:
    108         dis.skipBytes(8);
    109         i++;// 必须跳过一个,这是class文件设计的一个缺陷,历史遗留问题
    110         break;
    111       case 12:// CONSTANT_NameAndType:
    112         dis.skipBytes(2);
    113         dis.skipBytes(2);
    114         break;
    115       case 1:// CONSTANT_Utf8:
    116         int len = dis.readUnsignedShort();
    117         byte[] data = new byte[len];
    118         dis.read(data);
    119         strs.put(i + 1, new String(data, "UTF-8"));// 必然是UTF8的
    120         break;
    121       case 15:// CONSTANT_MethodHandle:
    122         dis.skipBytes(1);
    123         dis.skipBytes(2);
    124         break;
    125       case 16:// CONSTANT_MethodType:
    126         dis.skipBytes(2);
    127         break;
    128       case 18:// CONSTANT_InvokeDynamic:
    129         dis.skipBytes(2);
    130         dis.skipBytes(2);
    131         break;
    132       default:
    133         throw new RuntimeException("Impossible!! flag=" + flag);
    134       }
    135     }
    136     dis.skipBytes(2);// 版本控制符
    137     dis.skipBytes(2);// 类名
    138     dis.skipBytes(2);// 超类
    139     // 跳过接口定义
    140     int interfaces_count = dis.readUnsignedShort();
    141     dis.skipBytes(2 * interfaces_count);// 每个接口数据,是2个字节
    142     // 跳过字段定义
    143     int fields_count = dis.readUnsignedShort();
    144     for (int i = 0; i < fields_count; i++) {
    145       dis.skipBytes(2);
    146       dis.skipBytes(2);
    147       dis.skipBytes(2);
    148       int attributes_count = dis.readUnsignedShort();
    149       for (int j = 0; j < attributes_count; j++) {
    150         dis.skipBytes(2);// 跳过访问控制符
    151         int attribute_length = dis.readInt();
    152         dis.skipBytes(attribute_length);
    153       }
    154     }
    155     // 开始读取方法
    156     int methods_count = dis.readUnsignedShort();
    157     for (int i = 0; i < methods_count; i++) {
    158       dis.skipBytes(2); // 跳过访问控制符
    159       String methodName = strs.get(dis.readUnsignedShort());
    160       String descriptor = strs.get(dis.readUnsignedShort());
    161       short attributes_count = dis.readShort();
    162       for (int j = 0; j < attributes_count; j++) {
    163         String attrName = strs.get(dis.readUnsignedShort());
    164         int attribute_length = dis.readInt();
    165         if ("Code".equals(attrName)) { // 形参只在Code属性中
    166           dis.skipBytes(2);
    167           dis.skipBytes(2);
    168           int code_len = dis.readInt();
    169           dis.skipBytes(code_len); // 跳过具体代码
    170           int exception_table_length = dis.readUnsignedShort();
    171           dis.skipBytes(8 * exception_table_length); // 跳过异常表
    172           int code_attributes_count = dis.readUnsignedShort();
    173           for (int k = 0; k < code_attributes_count; k++) {
    174             int str_index = dis.readUnsignedShort();
    175             String codeAttrName = strs.get(str_index);
    176             int code_attribute_length = dis.readInt();
    177             if ("LocalVariableTable".equals(codeAttrName)) {// 形参在LocalVariableTable属性中
    178               int local_variable_table_length = dis
    179                   .readUnsignedShort();
    180               List<String> varNames = new ArrayList<String>(
    181                   local_variable_table_length);
    182               for (int l = 0; l < local_variable_table_length; l++) {
    183                 dis.skipBytes(2);
    184                 dis.skipBytes(2);
    185                 String varName = strs.get(dis
    186                     .readUnsignedShort());
    187                 dis.skipBytes(2);
    188                 dis.skipBytes(2);
    189                 if (!"this".equals(varName)) // 非静态方法,第一个参数是this
    190                   varNames.add(varName);
    191               }
    192               names.put(methodName + "," + descriptor, varNames);
    193             } else
    194               dis.skipBytes(code_attribute_length);
    195           }
    196         } else
    197           dis.skipBytes(attribute_length);
    198       }
    199     }
    200     dis.close();
    201     return names;
    202   }
    203   /**
    204    * 传入Method或Constructor,获取getParamNames方法返回的Map所对应的key
    205    */
    206   public static String getKey(Object obj) {
    207     StringBuilder sb = new StringBuilder();
    208     if (obj instanceof Method) {
    209       sb.append(((Method) obj).getName()).append(',');
    210       getDescriptor(sb, (Method) obj);
    211     } else if (obj instanceof Constructor) {
    212       sb.append("<init>,"); // 只有非静态构造方法才能用有方法参数的,而且通过反射API拿不到静态构造方法
    213       getDescriptor(sb, (Constructor<?>) obj);
    214     } else
    215       throw new RuntimeException("Not Method or Constructor!");
    216     return sb.toString();
    217   }
    218   public static void getDescriptor(StringBuilder sb, Method method) {
    219     sb.append('(');
    220     for (Class<?> klass : method.getParameterTypes())
    221       getDescriptor(sb, klass);
    222     sb.append(')');
    223     getDescriptor(sb, method.getReturnType());
    224   }
    225   public static void getDescriptor(StringBuilder sb,
    226       Constructor<?> constructor) {
    227     sb.append('(');
    228     for (Class<?> klass : constructor.getParameterTypes())
    229       getDescriptor(sb, klass);
    230     sb.append(')');
    231     sb.append('V');
    232   }
    233   /** 本方法来源于ow2的asm库的Type类 */
    234   public static void getDescriptor(final StringBuilder buf, final Class<?> c) {
    235     Class<?> d = c;
    236     while (true) {
    237       if (d.isPrimitive()) {
    238         char car;
    239         if (d == Integer.TYPE) {
    240           car = 'I';
    241         } else if (d == Void.TYPE) {
    242           car = 'V';
    243         } else if (d == Boolean.TYPE) {
    244           car = 'Z';
    245         } else if (d == Byte.TYPE) {
    246           car = 'B';
    247         } else if (d == Character.TYPE) {
    248           car = 'C';
    249         } else if (d == Short.TYPE) {
    250           car = 'S';
    251         } else if (d == Double.TYPE) {
    252           car = 'D';
    253         } else if (d == Float.TYPE) {
    254           car = 'F';
    255         } else /* if (d == Long.TYPE) */{
    256           car = 'J';
    257         }
    258         buf.append(car);
    259         return;
    260       } else if (d.isArray()) {
    261         buf.append('[');
    262         d = d.getComponentType();
    263       } else {
    264         buf.append('L');
    265         String name = d.getName();
    266         int len = name.length();
    267         for (int i = 0; i < len; ++i) {
    268           char car = name.charAt(i);
    269           buf.append(car == '.' ? '/' : car);
    270         }
    271         buf.append(';');
    272         return;
    273       }
    274     }
    275   }
    276 }
    使用案例: 

    1.使用缓存:

     1 /*
     2  * value:缓存中的键,${map.name}会动态替换为传入参数map里面的key为name的值。
     3  * comdition:缓存执行条件:!map.containsKey('execute')表示map中不包含execute这个key的时候才进行缓存操作。
     4  * 这里面的map是传入的参数名称。
     5  * 执行到该方法会自动去缓存里面查找该key,有就直接返回,没有就执行该方法,如果返回值不为空则同时存入缓存并返回结果。
     6  */
     7 @LoadFromMemcached(value="Resource_selectByMap_${map.name}",condition="!map.containsKey('execute')" )
     8 public List<Resource> selectByMap(Object map) {
     9      return super.selectByMap(map);
    10 }

    表示执行该method(selectByMap)的时候会首先去缓存组件中查找数据,如果查找到数据就直接返回,如果找不到数据就执行方法体,并将返回值记录入缓存中。

    2.更新缓存:

    1 /*
    2  * 同样value为缓存中的key,${t.name}会动态替换为update方法传入参数Resource的name字段
    3  * comdition:字段作用同上,不演示了
    4  */
    5 @UpdateForMemcached(value="Resource_selectByMap_${t.name}")
    6 public int update(Resource t) {
    7         return super.update(t);
    8 }

    表示执行该method(update)的时候会同步将缓存中的key置为过期(并不是把该方法的返回值放入缓存,只是将对应的缓存设为过期,下次再执行selectByMap的时候获取的就是最新的数据了)。

    扩展:

    本文只是简单的解决方案,可能有很多不足的地方,欢迎交流,以此简单的结构为基础进行扩展,将MemcachedClient以及相关的缓存操作方法提取出来并完善细节即可完成基本通用的缓存组件。

     

  • 相关阅读:
    【学习笔记/题解】树上启发式合并/CF600E Lomsat gelral
    【学习笔记/题解】虚树/[SDOI2011]消耗战
    【题解】 [GZOI2017]小z玩游戏
    【题解】CF1426E Rock, Paper, Scissors
    【题解】CF1426D Non-zero Segments
    【题解】NOIP2018 填数游戏
    【题解】NOIP2018 旅行
    【题解】NOIP2018 赛道修建
    【题解】时间复杂度
    【题解】「MCOI-02」Convex Hull 凸包
  • 原文地址:https://www.cnblogs.com/libaoting/p/Annotation.html
Copyright © 2020-2023  润新知