SpringCache实现原理及核心业务逻辑(三)

来源:互联网 发布:java属性签名是什么 编辑:程序博客网 时间:2024/06/04 08:34
SpringCache是SpringFramework3.1引入的新特性,提供了基于注解的缓存配置方法。它本质上不是一个具体的缓存实现方案(例如EHCache),而是一个对缓存使用的抽象,通过在已有代码中打上几个预定义的注释,就可以实现我们希望达到的缓存效果。SpringCache支持跟第三方缓存例如EHCache集成;另外也提供了开箱即用的默认实现,可以直接拿来使用。
SpringCache支持使用SpEL(Spring Expression Language)来定义缓存的key和各种condition,因此具备相当的灵活性,并可以支持非常复杂的语义。
下面先给出一个使用案例,然后通过源码分析其实现原理及核心业务逻辑。


第三部分:SpringCache核心业务逻辑

1 核心概念及用法介绍

1.1 Cache注释的说明及用法

@EnableCaching
作用:标注在Configuration类上,用于启用Cache注解,代码如下:

@Import(CachingConfigurationSelector.class)public @interface EnableCaching {boolean proxyTargetClass() default false;//动态代理模式中,Spring AOP对于具体类的代理是使用JavaProxy还是cglibAdviceMode mode() default AdviceMode.PROXY;//Spring AOP使用动态代理还是原生ASPECTJ来实现int order() default Ordered.LOWEST_PRECEDENCE;//启动顺序}
打上@EnableCaching就能启动注释风格的SpringCache功能的具体实现机制在上一篇中专门做了介绍,这里不再赘述。


@CachePut 
作用:标注到写数据的方法上,如新增/修改方法,调用方法时会自动把符合条件的数据存入缓存,代码如下: 

public @interface CachePut {        String[] value();              //缓存的名字,可以把数据写到多个缓存        String key() default "";       //缓存key,如果不指定将使用默认的KeyGenerator生成    String condition() default ""; //满足缓存条件的数据才会放入缓存,condition在调用方法之前和之后都会判断        String unless() default "";    //用于否决缓存更新的,不像condition,该表达只在方法执行之后判断,此时可以拿到返回值result进行判断了    }
使用示例如下:
@CachePut(value = "mycache", key = "#user.id", condition = "#user.id ne 12")    public User save(User user) {        return user;    }
上述配置的含义是:在调用该方法时,会把符合条件的(user.id!=12的)user.id作为key,返回值作为value放入缓存;系统中可能存在多个cache,将返回结果保存在名称为"mycache"的缓存中。


@CacheEvict
作用:标注到移除数据的方法上,如删除方法,调用方法时会从缓存中移除符合条件的数据:

public @interface CacheEvict {        String[] value();                        //缓存的名字,可以从多个缓存中移除数据    String key() default "";                 //缓存key,如果不指定将使用默认的KeyGenerator生成    String condition() default "";           //满足缓存条件的数据才会从缓存中移除,condition在调用方法之前和之后都会判断    boolean allEntries() default false;      //是否移除所有数据    boolean beforeInvocation() default false;//是调用方法之前移除/还是调用之后移除    }
使用示例如下:
@CacheEvict(value = "mycache", key = "#user.id") public void delete(User user) {        users.remove(user);    }
上述配置的含义是:从名为mycache的缓存中移除key值为user.id的数据。
@CacheEvict(value={"mycache", "mycache2"}, allEntries = true)public void clearCache(){}
上述配置的含义是:清除mycache和mycache2中的所有缓存。

@Cacheable
作用:标注到读取数据的方法上,即可缓存的方法,如查找方法:先从缓存中读取,如果没有再调用方法获取数据,然后把数据添加到缓存中:

public @interface Cacheable {        String[] value();             //缓存的名字,可以从多个缓存中读取数据    String key() default "";      //缓存key,如果不指定将使用默认的KeyGenerator生成    String condition() default "";//满足缓存条件的数据才会从缓存中读取,或者在不存在的时候添加到缓存中    String unless() default "";   //用于否决缓存更新的,该表达只在方法执行之后判断,此时可以拿到返回值result进行判断了}
使用实例如下:
@Cacheable(value="mycache2", key = "#username.concat(#email)", condition = "#username eq 'wangd'")public User findByUsernameAndEmail(String username, String email){Random random = new Random();logger.info("cache2 miss, invoke find by name and email, name:" + username + ", email:"+email);   User user = new User(System.currentTimeMillis()+random.nextInt(10000),    username,    email);   return user;}
上述配置表示:在调用该方法时,会使用username+email作为key,查询缓存,如果缓存中存在该key则直接将其对应的value返回,返回值作为value放入缓存。

@Caching
作用:定义若干组的Cache注释,用于实现多个缓存逻辑。例如用户新增成功后,添加id-->user;username--->user;email--->user到缓存,代码如下:

public @interface Caching {        Cacheable[] cacheable() default {}; //声明多个@Cacheable        CachePut[] put() default {};        //声明多个@CachePut        CacheEvict[] evict() default {};    //声明多个@CacheEvict    }
使用示例如下:
@Caching(            put = {                    @CachePut(value = "mycache", key = "#user.id"),                    @CachePut(value = "mycache2", key = "#user.username.concat(#user.email)")           },        evict = {        @CacheEvict(value = "tempcache", key = "#user.id")        }  )    public User save(User user) {.............}
上述配置表示:在保存一个用户信息的时候,以user.id为key将该user缓存在mycache中,并以user.username+user.email为key将该user缓存在mycache2中,同时从tempcache中删除key值为user.id的缓存。

1.2 缓存条件condition和unless的执行时机及语法定义

1.2.1 condition与unless执行时机说明

上面在各个注释中都存在condition属性,其支持使用SpEL(Spring Expression Language),因此具备相当的灵活性,并可以支持非常复杂的语义。各个标注中的condition的执行时机略有不同,因此它们可以使用的SpEL上下文数据也略有不同。
  • @Cacheable中的condition是在执行方法之前用于被判断是否符合从缓存中读取,因此它无法使用返回值#result;而其unless是在执行方法之后做判断,因此它可以使用返回值#result。
  • @Cacheput中的condition和unless都是在执行方法之后用于被判断是否符合将结果保存到缓存中,因此它们都可以使用返回值#result。
  • @CacheEvict的condition由beforeInvocation的值来确定是在方法调用前还是在方法调用后执行。若beforeInvocation为true则condition在方法调用前执行;否则condition在方法调用后执行。

1.2.2 SpEL语法及可使用的上下文数据

SpEL语法在此就不再展开了,网上的相关资料也比较多。SpringCache提供了供SpEL定义使用的上下文数据,下表直接摘自Spring官方文档:
名字位置描述示例

methodName

root对象

当前被调用的方法名

#root.methodName

method

root对象

当前被调用的方法

#root.method.name

target

root对象

当前被调用的目标对象

#root.target

targetClass

root对象

当前被调用的目标对象类

#root.targetClass

args

root对象

当前被调用的方法的参数列表

#root.args[0]

caches

root对象

当前方法调用使用的缓存列表(如@Cacheable(value={"cache1", "cache2"})),则有两个cache

#root.caches[0].name

argument name

执行上下文

当前被调用的方法的参数,如findById(Long id),我们可以通过#id拿到参数

#user.id

result

执行上下文

方法执行后的返回值(仅当方法执行之后的判断有效,如‘unless’,'cache evict'的beforeInvocation=false)

#result

通过这些数据我们可能实现比较复杂的缓存逻辑。例如上面的condition中定义的:

#username.concat(#email)#user.id ne 12#username eq 'wangd'
等等都是使用了上下文数据的SpEL表达式。

2. Cache注释的解析

在上一篇文章中我们介绍了,AnnotationCacheOperationSource的作用是对方法上的Cache注释进行解析,并将其转化为对应的CacheOperation;执行注释解析动作的时机是在第一次调用该方法的时候(并缓存解析结果供后面方法调用时使用)。
AnnotationCacheOperationSource内部持有一个Set<CacheAnnotaionParser>的集合,默认只包含一个SpringCacheAnnotationParser。并使用CacheAnnotaionParser来实现AbstractFallbackCacheOperationSource定义的两个抽象模板方法:

    private final Set<CacheAnnotationParser> annotationParsers;public AnnotationCacheOperationSource() {this(true);}public AnnotationCacheOperationSource(boolean publicMethodsOnly) {this.publicMethodsOnly = publicMethodsOnly;this.annotationParsers = new LinkedHashSet<CacheAnnotationParser>(1);this.annotationParsers.add(new SpringCacheAnnotationParser());}    @Override      protected Collection<CacheOperation> findCacheOperations(final Class<?> clazz) {          return determineCacheOperations(new CacheOperationProvider() {              @Override              public Collection<CacheOperation> getCacheOperations(CacheAnnotationParser parser) {                  return parser.parseCacheAnnotations(clazz);              }          });      }          @Override      protected Collection<CacheOperation> findCacheOperations(final Method method) {          return determineCacheOperations(new CacheOperationProvider() {              @Override              public Collection<CacheOperation> getCacheOperations(CacheAnnotationParser parser) {                  return parser.parseCacheAnnotations(method);              }          });      }          protected Collection<CacheOperation> determineCacheOperations(CacheOperationProvider provider) {          Collection<CacheOperation> ops = null;          for (CacheAnnotationParser annotationParser : this.annotationParsers) {              Collection<CacheOperation> annOps = provider.getCacheOperations(annotationParser);              if (annOps != null) {                  if (ops == null) {                      ops = new ArrayList<CacheOperation>();                  }                  ops.addAll(annOps);              }          }          return ops;      }      /**      * Callback interface providing {@link CacheOperation} instance(s) based on      * a given {@link CacheAnnotationParser}.      */      protected interface CacheOperationProvider {              /**          * Returns the {@link CacheOperation} instance(s) provided by the specified parser.          *          * @param parser the parser to use          * @return the cache operations or {@code null} if none is found          */          Collection<CacheOperation> getCacheOperations(CacheAnnotationParser parser);      }  
该实现中使用回调模式,用Set<CacheAnnotaionParser>中的每一个CacheAnnotaionParser去解析一个方法或类,然后将得到的List<CacheOperation>合并,最终返回。
如上面代码所示,默认Set<CacheAnnotaionParser>中只有一个SpringCacheAnnotationParser,因此初次调用某方法的时候会在Cache切面上首先调用SpringCacheAnnotationParser的方法:

@Overridepublic Collection<CacheOperation> parseCacheAnnotations(Method method) {DefaultCacheConfig defaultConfig = getDefaultCacheConfig(method.getDeclaringClass());return parseCacheAnnotations(defaultConfig, method);}
来获取方法上的Cache相关注释,并将其封装成对应的CacheOperation集合并返回。其中:
DefaultCacheConfig defaultConfig = getDefaultCacheConfig(method.getDeclaringClass());
首先判断方法所在的类上是否配置了@CacheConfig注释,如果有就将该注释中的属性值设置到defaultConfig类中,用于后续配置。然后调用核心方法:
protected Collection<CacheOperation> parseCacheAnnotations(DefaultCacheConfig cachingConfig, AnnotatedElement ae) {Collection<CacheOperation> ops = null;Collection<Cacheable> cacheables = AnnotatedElementUtils.findAllMergedAnnotations(ae, Cacheable.class);if (!cacheables.isEmpty()) {ops = lazyInit(ops);for (Cacheable cacheable : cacheables) {ops.add(parseCacheableAnnotation(ae, cachingConfig, cacheable));}}Collection<CacheEvict> evicts = AnnotatedElementUtils.findAllMergedAnnotations(ae, CacheEvict.class);if (!evicts.isEmpty()) {ops = lazyInit(ops);for (CacheEvict evict : evicts) {ops.add(parseEvictAnnotation(ae, cachingConfig, evict));}}Collection<CachePut> puts = AnnotatedElementUtils.findAllMergedAnnotations(ae, CachePut.class);if (!puts.isEmpty()) {ops = lazyInit(ops);for (CachePut put : puts) {ops.add(parsePutAnnotation(ae, cachingConfig, put));}}Collection<Caching> cachings = AnnotatedElementUtils.findAllMergedAnnotations(ae, Caching.class);if (!cachings.isEmpty()) {ops = lazyInit(ops);for (Caching caching : cachings) {Collection<CacheOperation> cachingOps = parseCachingAnnotation(ae, cachingConfig, caching);if (cachingOps != null) {ops.addAll(cachingOps);}}}return ops;}
该方法中使用Spring Core模块的AnnotatedElementUtils来得到标注到可被标注对象(在这里包括类和方法)上的指定类型的注释,包括了注释上再打注释等层级结构以及层级属性的合并操作。
整体逻辑是首先获得标注在方法上的@Cacheable注释集合,并对其中的每个注释调用:

CacheableOperation parseCacheableAnnotation(AnnotatedElement ae, DefaultCacheConfig defaultConfig, Cacheable cacheable) {CacheableOperation.Builder builder = new CacheableOperation.Builder();builder.setName(ae.toString());builder.setCacheNames(cacheable.cacheNames());builder.setCondition(cacheable.condition());builder.setUnless(cacheable.unless());builder.setKey(cacheable.key());builder.setKeyGenerator(cacheable.keyGenerator());builder.setCacheManager(cacheable.cacheManager());builder.setCacheResolver(cacheable.cacheResolver());builder.setSync(cacheable.sync());defaultConfig.applyDefault(builder);CacheableOperation op = builder.build();validateCacheOperation(ae, op);return op;}
利用CacheableOperation.Builder来构建一个CacheableOperation,并添加到Collection<CacheOperation> ops中。接下来对@CacheEvict和@CachePut也执行同样的操作,区别是它们分别使用CacheEvictOperation.Builder和CachePutOperation.Builder来构建CacheEvictOperation和CachePutOperation。
另外对于@Caching,调用:

Collection<CacheOperation> parseCachingAnnotation(AnnotatedElement ae, DefaultCacheConfig defaultConfig, Caching caching) {Collection<CacheOperation> ops = null;Cacheable[] cacheables = caching.cacheable();if (!ObjectUtils.isEmpty(cacheables)) {ops = lazyInit(ops);for (Cacheable cacheable : cacheables) {ops.add(parseCacheableAnnotation(ae, defaultConfig, cacheable));}}CacheEvict[] cacheEvicts = caching.evict();if (!ObjectUtils.isEmpty(cacheEvicts)) {ops = lazyInit(ops);for (CacheEvict cacheEvict : cacheEvicts) {ops.add(parseEvictAnnotation(ae, defaultConfig, cacheEvict));}}CachePut[] cachePuts = caching.put();if (!ObjectUtils.isEmpty(cachePuts)) {ops = lazyInit(ops);for (CachePut cachePut : cachePuts) {ops.add(parsePutAnnotation(ae, defaultConfig, cachePut));}}return ops;}
其中对caching的cacheable、evict及put属性对应的各组@Cacheable、@CacheEvict及@CachePut注释分别调用前面介绍的执行逻辑来构建相应的CacheOperation并添加到Collection<CacheOperation> ops中,然后将其返回。
当首次调用某方法执行上述的解析操作后,AnnotationCacheOperationSource会将其缓存起来,后续再调用该方法时会直接从缓存中得到该方法对应的Collection<CacheOperation> ops以增加效率。

3. 根据CacheOperation执行核心Cache业务逻辑

3.1 核心业务逻辑的源码分析

就像上一篇中描述的那样,对于打了Cache相关注释的类,在创建其bean的时候已经由Spring AOP为其创建代理增强,并将BeanFactoryCacheOperationSourceAdvisor加入其代理中。

当调用其方法的时候会通过代理执行到BeanFactoryCacheOperationSourceAdvisor定义的切面。该切面是一个PointcutAdvisor,在SpringAOP底层框架DefaultAdvisorChainFactory.getInterceptorsAndDynamicInterceptionAdvice方法中使用

pointcutAdvisor.getPointcut().getMethodMatcher().matches(method, targetClass))

来判断被调用方法是否匹配切点逻辑,如果匹配就执行其拦截器中的逻辑,BeanFactoryCacheOperationSourceAdvisor中注册的拦截器是CacheInterceptor,其执行逻辑为:
@Overridepublic Object invoke(final MethodInvocation invocation) throws Throwable {Method method = invocation.getMethod();CacheOperationInvoker aopAllianceInvoker = new CacheOperationInvoker() {@Overridepublic Object invoke() {try {return invocation.proceed();}catch (Throwable ex) {throw new ThrowableWrapper(ex);}}};try {return execute(aopAllianceInvoker, invocation.getThis(), method, invocation.getArguments());}catch (CacheOperationInvoker.ThrowableWrapper th) {throw th.getOriginal();}}
其中Spring AOP底层调用该方法时传递来的参数MethodInvocation invocation是一个把调用方法method与其对应的切面拦截器interceptors组装成类似于ChainFilter一样的结构。
CacheOperationInvoker回调接口的意义是将决定切面逻辑与实际调用方法顺序的权利转交给CacheAspectSupport的execute方法。
该逻辑中调用了CacheAspectSupport的方法:

protected Object execute(CacheOperationInvoker invoker, Object target, Method method, Object[] args) {// Check whether aspect is enabled (to cope with cases where the AJ is pulled in automatically)if (this.initialized) {Class<?> targetClass = getTargetClass(target);Collection<CacheOperation> operations = getCacheOperationSource().getCacheOperations(method, targetClass);if (!CollectionUtils.isEmpty(operations)) {return execute(invoker, method, new CacheOperationContexts(operations, method, args, target, targetClass));}}return invoker.invoke();}
该方法首先通过前面描述的AnnotationCacheOperationSource.getCacheOperations(method, targetClass)来获得调用方法上的Collection<CacheOperation>,然后将其和调用方法method、方法参数args、目标对象target、目标类targetClass一起创建CacheOperationContexts。
public CacheOperationContexts(Collection<? extends CacheOperation> operations, Method method,Object[] args, Object target, Class<?> targetClass) {for (CacheOperation operation : operations) {this.contexts.add(operation.getClass(), getOperationContext(operation, method, args, target, targetClass));}this.sync = determineSyncFlag(method);}
其中,为每个operation分别创建CacheOperationContext:
protected CacheOperationContext getOperationContext(CacheOperation operation, Method method, Object[] args, Object target, Class<?> targetClass) {CacheOperationMetadata metadata = getCacheOperationMetadata(operation, method, targetClass);return new CacheOperationContext(metadata, args, target);}
获取CacheOperationMetadata metadata时的较重要的动作就是获取CacheOperation中用String名称定义的CacheResolver和KeyGenerator的bean。
然后在创建CacheOperationContext时使用CacheResolver bean获得cache的信息:

public CacheOperationContext(CacheOperationMetadata metadata, Object[] args, Object target) {this.metadata = metadata;this.args = extractArgs(metadata.method, args);this.target = target;this.caches = CacheAspectSupport.this.getCaches(this, metadata.cacheResolver);this.cacheNames = createCacheNames(this.caches);this.methodCacheKey = new AnnotatedElementKey(metadata.method, metadata.targetClass);}

在创建完上下文CacheOperationContexts后,调用SpringCache真正的核心业务逻辑:
private Object execute(final CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts)

该方法执行逻辑如下:

#首先判断是否需要在方法调用前执行缓存清除:

processCacheEvicts(contexts.get(CacheEvictOperation.class), true, CacheOperationExpressionEvaluator.NO_RESULT);
其作用是判断是否需要在方法调用前执行缓存清除。判断是否存在beforeInvocation==true并且condition符合条件的@CacheEvict注释,如果存在则最终执行方法:
private void performCacheEvict(CacheOperationContext context, CacheEvictOperation operation, Object result) {Object key = null;for (Cache cache : context.getCaches()) {if (operation.isCacheWide()) {logInvalidating(context, operation, null);doClear(cache);}else {if (key == null) {key = context.generateKey(result);}logInvalidating(context, operation, key);doEvict(cache, key);}}}

对于注释中定义的每一个cache都根据allEntries是否为true执行其clear()方法或evict(key)方法来清除全部或部分缓存。


#然后检查是否能得到一个符合条件的缓存值:

Cache.ValueWrapper cacheHit = findCachedItem(contexts.get(CacheableOperation.class));

其中调用了cache.get(key),在此就不再贴出。


#随后如果Cacheable miss(没有获取到缓存),就会创建一个对应的CachePutRequest并收集起来:

List<CachePutRequest> cachePutRequests = new LinkedList<CachePutRequest>();if (cacheHit == null) {collectPutRequests(contexts.get(CacheableOperation.class),CacheOperationExpressionEvaluator.NO_RESULT, cachePutRequests);}
注意,此时方法尚未执行,因此第二个参数为CacheOperationExpressionEvaluator.NO_RESULT,意思是当前上下文中#result不存在。


#接下来判断返回缓存值还是实际调用方法的结果

如果得到了缓存,并且CachePutRequests为空并且不含有符合条件(condition match)的@CachePut注释,那么就将returnValue赋值为缓存值;否则实际执行方法,并将returnValue赋值为方法返回值。

Object cacheValue;Object returnValue;if (cacheHit != null && cachePutRequests.isEmpty() && !hasCachePut(contexts)) {// If there are no put requests, just use the cache hitcacheValue = cacheHit.get();if (method.getReturnType() == javaUtilOptionalClass &&(cacheValue == null || cacheValue.getClass() != javaUtilOptionalClass)) {returnValue = OptionalUnwrapper.wrap(cacheValue);}else {returnValue = cacheValue;}}else {// Invoke the method if we don't have a cache hitreturnValue = invokeOperation(invoker);if (returnValue != null && returnValue.getClass() == javaUtilOptionalClass) {cacheValue = OptionalUnwrapper.unwrap(returnValue);}else {cacheValue = returnValue;}}


#方法调用后收集@CachePut明确定义的CachePutRequest

收集符合条件的@CachePut定义的CachePutRequest,并添加到上面的cachePutRequests中:

collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);

注意,此时处于方法调用后,返回结果已经存在了,因此在condition定义中可以使用上下文#result。


#执行CachePutRequest将符合条件的数据写入缓存

对于上面收集到的cachePutRequests,逐个调用其apply(cacheValue)方法:

for (CachePutRequest cachePutRequest : cachePutRequests) {cachePutRequest.apply(cacheValue);}
其中,CachePutRequest.apply方法首先判断unless条件,unless不符合的时候才会对operationContext中的每一个cache执行put动作:
public void apply(Object result) {if (this.context.canPutToCache(result)) {for (Cache cache : this.context.getCaches()) {doPut(cache, this.key, result);}}}
判断是否执行put动作的方法如下:
protected boolean canPutToCache(Object value) {String unless = "";if (this.metadata.operation instanceof CacheableOperation) {unless = ((CacheableOperation) this.metadata.operation).getUnless();}else if (this.metadata.operation instanceof CachePutOperation) {unless = ((CachePutOperation) this.metadata.operation).getUnless();}if (StringUtils.hasText(unless)) {EvaluationContext evaluationContext = createEvaluationContext(value);return !evaluator.unless(unless, this.methodCacheKey, evaluationContext);}return true;}


#最后判断是否需要在方法调用后执行缓存清除:

processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
与步骤(1)相对应,其作用是判断是否需要在方法调用后执行缓存清除。判断是否存在beforeInvocation==false并且condition符合条件的@CacheEvict注释,注意此时上下文中结果#result是可用的。如果存在则最终执行缓存清除逻辑。

3.2 整体执行业务逻辑概述

  1. 首先执行@CacheEvict(如果beforeInvocation=true且condition通过),如果allEntries=true,则清空所有  
  2. 然后收集@Cacheable并检查是否能得到一个符合条件的缓存值
  3. 如果@Cacheable的condition通过,并且key对应的数据不在缓存中,就创建一个CachePutRequest实例放入cachePutRequests中  
  4. 如果得到了缓存值并且cachePutRequests为空并且没有符合条件的@CachePut操作,那么将returnValue=缓存数据 
  5. 如果没有找到缓存,那么实际执行方法调用,并把返回结果放入returnValue  
  6. 收集符合条件的@CachePut操作(此时是方法执行后,condition上下文中#result可用),并放入cachePutRequests  
  7. 执行cachePutRequests,将数据写入缓存(unless为空或者unless解析结果为false);  
  8. 执行@CacheEvict(如果beforeInvocation=false且condition通过),如果allEntries=true,则清空所有  


小结:至此,SpringCache基本概念、实现原理及业务逻辑就为大家全部介绍完毕了,具体的使用及最佳实践还是需要各自针对具体的应用场景来摸索的,没有一劳永逸的解决方案。但在了解底层的实现原理及业务逻辑以后,再去配置、使用甚至定制,都会变得游刃有余了。




相关文章:SpringCache实现原理及核心业务逻辑(一)

相关文章:SpringCache实现原理及核心业务逻辑(二)

阅读全文
0 0