zoukankan      html  css  js  c++  java
  • 如何实现一个缓存服务

      场景:我们对于需要大量计算的场景,希望将结果缓存起来,然后我们一起来实现一个缓存服务。即对于一个相同的输入,它的输出是不变的(也可以短时间不变)

    实现说明:这里实现采用GuavaCache+装饰器模式。

    首先设计一个缓存服务接口。

    public interface CacheableService<I, O> {
    
        /**
         * 计算服务
         * @param i
         * @return
         * @throws Exception 
         */
        O doService(I i) throws Exception;
    }

    这里定义了一个缓存服务接口,这里的key和Hashmap的key一样,需要覆写equals和hashcode方法。

    public class CacheableServiceWrapper<I , O> implements
            CacheableService<I, O>,
            GlobalResource {
    
        /**
         * 日志
         */
        private final static Logger LOGGER = LoggerFactory
                .getLogger(CacheableServiceWrapper.class);
    
        /**
         * 缓存大小
         */
        private int MAX_CACHE_SIZE = 20;
    
        /**
         * 出现异常的时候重试,默认不重试
         */
        private boolean retryOnExp = false;
    
        /**
         * 重试次数,默认为0,即不重试
         */
        private int retryTimes = 0;
    
        /**
         * 默认30分钟
         */
        private long expireTimeWhenAccess = 30 * 60;
    
        /**
         * 缓存
         */
        private LoadingCache<I, Future<O>> cache = null;
    
        private CacheableService<I, O> cacheableService = null;
    
        /**
         * Calculate o.
         *
         * @param i the
         * @return the o
         * @throws Exception the exception
         */
        public O doService(final I i) throws Exception {
    
            Assert.notNull(cacheableService, "请设置好实例");
    
            int currentTimes = 0;
            while (currentTimes <= retryTimes) {
                try {
                    Future<O> oFuture = cache.get(i);
                    return oFuture.get();
    
                } catch (Exception e) {
                    if (!retryOnExp) {
                        throw e;
                    }
                    currentTimes++;
                    LoggerUtils.info(LOGGER, "第", currentTimes, "重试,key=", i);
                }
            }
            throw new Exception("任务执行失败");
        }
    
    
        /**
         * 提交计算任务
         *
         * @param i
         * @return
         */
        private Future<O> createTask(final I i) {
            Assert.notNull(cacheableService, "请设置好实例");
    
            LoggerUtils.info(LOGGER, "提交任务,key=", i);
            LoggerUtils.info(LOGGER, "当前cache=", JSON.toJSONString(cache));
    
            Future<O> resultFuture = THREAD_POOL.submit(new Callable<O>() {
    
                public O call() throws Exception {
                    return cacheableService.doService(i);
                }
            });
            return resultFuture;
    
        }
    
        /**
         * 构造函数
         */
        public CacheableServiceWrapper(CacheableService<I, O> cacheableService,
                                                int maxCacheSize, long expireTime) {
            this.cacheableService = cacheableService;
            this.MAX_CACHE_SIZE = maxCacheSize;
            this.expireTimeWhenAccess = expireTime;
            cache = CacheBuilder.newBuilder().maximumSize(MAX_CACHE_SIZE)
                    .expireAfterAccess(expireTimeWhenAccess, TimeUnit.SECONDS)
                    .build(new CacheLoader<I, Future<O>>() {
                        public Future<O> load(I key) throws ExecutionException {
                            LoggerUtils.warn(LOGGER, "get Element from cacheLoader");
                            return createTask(key);
                        }
    
                        ;
                    });
        }
    
        /**
         * 构造函数
         */
        public CacheableServiceWrapper(CacheableService<I, O> cacheableService) {
            this.cacheableService = cacheableService;
            cache = CacheBuilder.newBuilder().maximumSize(MAX_CACHE_SIZE)
                    .expireAfterAccess(expireTimeWhenAccess, TimeUnit.SECONDS)
                    .build(new CacheLoader<I, Future<O>>() {
                        public Future<O> load(I key) throws ExecutionException {
                            LoggerUtils.warn(LOGGER, "get Element from cacheLoader");
                            return createTask(key);
                        }
    
                        ;
                    });
        }
    
        /**
         * Setter method for property <tt>retryTimes</tt>.
         *
         * @param retryTimes value to be assigned to property retryTimes
         */
        public void setRetryTimes(int retryTimes) {
            this.retryTimes = retryTimes;
        }
    
        /**
         * Setter method for property <tt>retryOnExp</tt>.
         *
         * @param retryOnExp value to be assigned to property retryOnExp
         */
        public void setRetryOnExp(boolean retryOnExp) {
            this.retryOnExp = retryOnExp;
        }
    
    }
    缓存服务装饰器

    这个装饰器就是最主要的内容了,实现了对缓存服务的输入和输出的缓存。这里先说明下中间几个重要的属性:

    MAX_CACHE_SIZE :缓存空间的大小
    retryOnExp :当缓存服务发生异常的时候,是否发起重试
    retryTimes :当缓存服务异常需要重试的时候,重新尝试的最大上限。
    expireTimeWhenAccess : 缓存失效时间,当key多久没有访问的时候,淘汰数据

    然后是doService采用了Guava的缓存机制,当获取缓存为空的时候,会自动去build缓存,这个操作是原子化的,所以不用自己去采用ConcurrentHashmap的putIfAbsent方法去做啦~~~
    这里面实现了最主要的逻辑,就是获取缓存,然后去get数据,然后如果异常,根据配置去重试。

    好啦现在咱们去测试啦
    public class CacheableCalculateServiceTest {
    
        private CacheableService<String, String> calculateService;
    
        @Before
        public void before() {
            CacheableServiceWrapper<String, String> wrapper = new CacheableServiceWrapper<String, String>(
                new CacheableService<String, String>() {
    
                    public String doService(String i) throws Exception {
                        Thread.sleep(999);
                        return i + i;
                    }
                });
            wrapper.setRetryOnExp(true);
            wrapper.setRetryTimes(2);
            calculateService = wrapper;
        }
    
        @Test
        public void test() throws Exception {
            MutiThreadRun.init(5).addTaskAndRun(300, new Callable<String>() {
    
                public String call() throws Exception {
                    return calculateService.doService("1");
                }
            });
        }

    这里我们为了模拟大量计算的场景,我们将线程暂停了999ms,然后使用5个线程,执行任务999次,结果如下:

    2016-08-24 02:00:18:848 com.zhangwei.learning.calculate.CacheableServiceWrapper get Element from cacheLoader
    2016-08-24 02:00:20:119 com.zhangwei.learning.calculate.CacheableServiceWrapper 提交任务,key=1
    2016-08-24 02:00:20:122 com.zhangwei.learning.calculate.CacheableServiceWrapper 当前cache={}
    2016-08-24 02:00:21:106 com.zhangwei.learning.jedis.JedisPoolMonitorTask poolSize=500 borrowed=0 idle=0
    2016-08-24 02:00:21:914 com.zhangwei.learning.run.MutiThreadRun 任务执行完毕,执行时间3080ms,共有300个任务,执行异常0次

    可以看到,由于key一样,只执行了一次计算,然后剩下299都是从缓存中获取的。

    现在我们修改为5个线程,执行300000次。

    2016-08-24 02:03:15:013 com.zhangwei.learning.calculate.CacheableServiceWrapper get Element from cacheLoader
    2016-08-24 02:03:16:298 com.zhangwei.learning.calculate.CacheableServiceWrapper 提交任务,key=1
    2016-08-24 02:03:16:300 com.zhangwei.learning.calculate.CacheableServiceWrapper 当前cache={}
    2016-08-24 02:03:17:289 com.zhangwei.learning.jedis.JedisPoolMonitorTask poolSize=500 borrowed=0 idle=0
    2016-08-24 02:03:18:312 com.zhangwei.learning.run.MutiThreadRun 任务执行完毕,执行时间3317ms,共有300000个任务,执行异常0次

    发现,执行时间没啥区别啦~~~~缓存的效果真是棒棒的~~

    PS:我的个人svn地址:http://code.taobao.org/p/learningIT/wiki/index/   有兴趣的可以看下啦~

    后面我们再看基于注解去实现缓存~~~

    好啦继续更新,我们使用注解,来实现缓存,首先我们的前提还是跟上面的一样,是对方法做缓存,也就是将方法的输入到输出的映射做缓存。

    首先来个注解:

    @Target({ ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface Cache {
    
        /**
         * 是否打印
         * @return
         */
        public boolean enabled() default true;
    
        /**
         * Cache type cache type.
         *
         * @return the cache type
         */
        public CacheType cacheType() default CacheType.LOCAL;
    
    }

    该注解是注解在方法上的

    package com.zhangwei.learning.utils.cache;
    
    import com.alibaba.fastjson.JSON;
    import com.google.common.collect.Lists;
    import com.zhangwei.learning.model.ToString;
    import com.zhangwei.learning.utils.log.LoggerUtils;
    import org.slf4j.LoggerFactory;
    
    import java.lang.reflect.Method;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.Collections;
    import java.util.List;
    
    /**
     * 用于缓存的key,如果接口需要缓存,那么复杂对象参数都需要实现这个接口
     * Created by Administrator on 2016/8/22.
     */
    public class CacheKey extends ToString {
    
        /**
         * The A class.
         */
        private String classPath;
    
        /**
         * The Method.
         */
        private Method method;
    
        /**
         * The Input params.
         */
        private List<Object> inputParams;
    
        /**
         * Instantiates a new Cache key.
         *
         * @param clazz  the clazz
         * @param method the method
         * @param inputs the inputs
         */
        public CacheKey(Class clazz, Method method, Object[] inputs) {
            this.classPath = clazz.getName();
            this.method = method;
            List<Object> list = Lists.newArrayList();
            if(inputs==null || inputs.length==0){
                inputParams = list;
            }
            for(Object o : inputs){
                list.add(o);
            }
            inputParams = list;
        }
    
        /**
         * Equals boolean.
         *
         * @param obj the obj
         * @return the boolean
         */
        @Override
        public boolean equals(Object obj) {
            if (obj == null || !(obj instanceof CacheKey)) {
                return false;
            }
            CacheKey key = (CacheKey) obj;
            if (classPath.equals(key.getClassPath()) && method.equals(key.getMethod())) {
                if (key.getInputParams().size() != getInputParams().size()) {
                    return false;
                }
                for (int i = 0; i < inputParams.size(); i++) {
                    Object param = getInputParams().get(i);
                    //如果有自定义的convertor,那么使用自定义的convertor
                    ObjEqualsConvertor convertor = CacheInterceptor.getConvertors().get(param.getClass().getName());
                    if(convertor !=null){
                        if(!convertor.extraEquals(param,key.getInputParams().get(i))){
                            return false;
                        }
                        continue;
                    }
                    if (!getInputParams().get(i).equals(key.getInputParams().get(i))) {
                        return false;
                    }
                }
                return true;
            }
            return false;
        }
    
        /**
         * Hash code int.
         *
         * @return the int
         */
        @Override
        public int hashCode() {
            return classPath.hashCode()+method.hashCode()+inputParams.hashCode();
        }
    
        /**
         * Gets class path.
         *
         * @return the class path
         */
        public String getClassPath() {
            return classPath;
        }
    
        /**
         * Sets class path.
         *
         * @param classPath the class path
         */
        public void setClassPath(String classPath) {
            this.classPath = classPath;
        }
    
        /**
         * Gets method.
         *
         * @return the method
         */
        public Method getMethod() {
            return method;
        }
    
        /**
         * Sets method.
         *
         * @param method the method
         */
        public void setMethod(Method method) {
            this.method = method;
        }
    
        /**
         * Gets input params.
         *
         * @return the input params
         */
        public List<Object> getInputParams() {
            return inputParams;
        }
    
        /**
         * Sets input params.
         *
         * @param inputParams the input params
         */
        public void setInputParams(List<Object> inputParams) {
            this.inputParams = inputParams;
        }
    }

    我们要做缓存,肯定要有个key,这里就是我们定义的key,最主要的是我们使用了一个专门的类,主要包含调用的类、方法、以及入参。这里有下面几个需要注意的点:

    1、需要修改equals方法,这点跟hashmap自定义key一样。

    2、比较类的时候直接用class全名。如果用class的equals方法,有可能class地址不一致导致判断有问题。这里method的equals方法已经是覆写了,所以没问题。

    3、hashcode使用三个参数合起来的hashcode,这样尽量让key散列到不同的捅,如果用classpath的,那么如果这个类调用量很大,其他的类调用很少,那么桶分布就很不均匀了。

    4、入参都需要注意下equals方法,但是对于有些类我们没有办法修改它的equals方法,这个时候我们有个转换map,可以自定义对某个类的equal比较器,然后可以在不对类的修改的情况下,达到比较的效果。

    上面实现了注解和缓存的key,下面来拦截器啦

    /**
     * Alipay.com Inc.
     * Copyright (c) 2004-2016 All Rights Reserved.
     */
    package com.zhangwei.learning.utils.cache;
    
    import com.google.common.cache.CacheBuilder;
    import com.google.common.cache.RemovalListener;
    import com.google.common.cache.RemovalNotification;
    import com.google.common.collect.Maps;
    import com.zhangwei.learning.resource.GlobalResource;
    import com.zhangwei.learning.utils.log.LoggerUtils;
    import org.aopalliance.intercept.Invocation;
    import org.aopalliance.intercept.MethodInterceptor;
    import org.aopalliance.intercept.MethodInvocation;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.InitializingBean;
    
    import java.util.Map;
    import java.util.concurrent.*;
    
    /**
     * 可以对接口做缓存的拦截器
     *
     * @author Administrator
     * @version $Id: CacheInterceptor.java, v 0.1 2016年8月22日 上午2:50:32 Administrator Exp $
     */
    public class CacheInterceptor implements MethodInterceptor, InitializingBean, GlobalResource {
    
        /**
         * The constant logger.
         */
        private static final Logger logger = LoggerFactory
                .getLogger(CacheInterceptor.class);
    
        /**
         * 本地缓存大小.
         */
        private long maxCacheSize = 300;
    
        /**
         * The constant expireTimeWhenAccess.
         */
        private long expireTimeWhenAccess = 20;
    
        /**
         * The Local Cache.
         */
        private com.google.common.cache.Cache<CacheKey, FutureTask<Object>> cache = null;
    
        /**
         * The equal Convertors.
         */
        private static Map<String, ObjEquality> convertors = Maps.newHashMap();
    
        /**
         * @see org.aopalliance.intercept.MethodInterceptor#invoke(org.aopalliance.intercept.MethodInvocation)
         */
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            Cache cacheAnnotation = invocation.getMethod().getAnnotation(Cache.class);
            if (cacheAnnotation == null || !cacheAnnotation.enabled()) {
                return invocation.proceed();
            }
            //需要cache
            CacheKey cacheKey = new CacheKey(invocation.getMethod().getDeclaringClass(), invocation.getMethod(), invocation.getArguments());
            CacheType cacheType = cacheAnnotation.cacheType();
            if (cacheType == CacheType.LOCAL) {
                Object result = getLocalCacheResult(cacheKey, invocation);
                return result;
            }
            throw new RuntimeException("not supported cacheType");
        }
    
        /**
         * Get local cache result object.
         *
         * @param key the key
         * @return the object
         */
    
        private Object getLocalCacheResult(CacheKey key, final Invocation i) throws ExecutionException, InterruptedException {
            FutureTask<Object> f = new FutureTask<Object>(new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    try {
                        return i.proceed();
                    } catch (Throwable throwable) {
                        throw new ExecutionException(throwable);
                    }
                }
            });
            FutureTask<Object> result = cache.asMap().putIfAbsent(key, f);
            if (result == null) {
                f.run();
                result = f;
                LoggerUtils.debug(logger,"提交任务,key=",key);
            }else {
                LoggerUtils.debug(logger, "从缓存获取,key=", key);
            }
            return result.get();
        }
    
        /**
         * Sets expire time when access.
         *
         * @param expireTimeWhenAccess the expire time when access
         */
        public void setExpireTimeWhenAccess(long expireTimeWhenAccess) {
            this.expireTimeWhenAccess = expireTimeWhenAccess;
        }
    
        @Override
        public void afterPropertiesSet() throws Exception {
            cache = CacheBuilder
                    .newBuilder()
                    .maximumSize(maxCacheSize)
                    .expireAfterAccess(
                            expireTimeWhenAccess,
                            TimeUnit.SECONDS).removalListener(new RemovalListener<CacheKey, Future<Object>>() {
                        @Override
                        public void onRemoval(RemovalNotification<CacheKey, Future<Object>> notification) {
                            LoggerUtils.info(logger, "移除key=", notification.getKey(), ",value=", notification.getValue(), ",cause=", notification.getCause());
                        }
                    })
                    .build();
        }
    
        /**
         * Sets convertors.
         *
         * @param convertors the convertors
         */
        public void setConvertors(Map<String, ObjEquality> convertors) {
            this.convertors = convertors;
        }
    
        /**
         * Gets convertors.
         *
         * @return the convertors
         */
        public static Map<String, ObjEquality> getConvertors() {
            return convertors;
        }
    
        /**
         * Sets max cache size.
         *
         * @param maxCacheSize the max cache size
         */
        public void setMaxCacheSize(long maxCacheSize) {
            this.maxCacheSize = maxCacheSize;
        }
    }
    缓存拦截器

    这里我们实现了缓存的拦截器,缓存采用Guava cache,这里我们在使用上主要是使用了guava的缓存自动淘汰、原子化的功能。我们可以看到,缓存的是CacheKey--->FutureTask<Object>的映射,这里我们采用了FutureTask的异步执行的功能。并且将Guava 作为ConcurrentHashMap来使用。

    好了我们来配置下。

    <bean id="cacheInteceptor" class="com.zhangwei.learning.utils.cache.CacheInterceptor">
            <property name="maxCacheSize" value="300"/>
            <property name="expireTimeWhenAccess" value="300"/>
            <property name="convertors">
                <map>
                    <entry key="java.lang.String" value-ref="stringConvertor" />
                </map>
            </property>
        </bean>
        <bean class="org.springframework.aop.framework.autoproxy.BeanNameAutoProxyCreator">
            <property name="order" value="90"></property>        
            <property name="interceptorNames">
                <list>
                    <value>digestInteceptor</value>
                    <value>cacheInteceptor</value>
                </list>
            </property>
            <property name="beanNames">
                <value>*</value>
            </property>
        </bean>

    上面的那个map就是配置的自定义equals比较器

    上测试类

    @Component
    @Digest
    public class TestBean {
    
        @Cache(cacheType = CacheType.LOCAL, enabled = true)
        public String test(String one, String two) throws Exception {
            Thread.sleep(999);
            //        throw new Exception("lalal");
            return one + two;
        }
    }
    public class CacheTest {
    
        private final static Logger LOGGER = LoggerFactory.getLogger(CacheTest.class);
    
        @org.junit.Test
        public void Test() {
            final TestBean client = GlobalResourceUtils.getBean(TestBean.class);
            LoggerUtils.info(LOGGER, "获取到的client=", JSON.toJSONString(client));
            MutiThreadRun.init(5).addTaskAndRun(10, new Callable<Object>() {
                @Override
                public Object call() throws Exception {
                    return client.test("aaa","bbb");
                }
            });
        }
    }
    public class StringConvertor extends ObjEquality {
        @Override
        public boolean extraEquals(Object a, Object b) {
            return false;
        }
    }

    这里我们就是讲一个方法多线程执行了10次,该方法中间会将线程暂停1s,所以可以看每次方法的执行时间就知道是否走缓存了。我们这里自定义了一个equal比较器,总是返回false,所以这里我们理论上每次都不会走缓存的,因为比较的时候key不同。

    2016-08-28 23:38:09:527 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1043ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:09:530 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1035ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:09:530 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1034ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:09:531 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1036ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:09:534 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1033ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:527 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1000ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:530 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1000ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:531 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1000ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:531 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1000ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:534 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1000ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:38:10:534 com.zhangwei.learning.run.MutiThreadRun 任务执行完毕,执行时间2051ms,共有10个任务,执行异常0次

    可以看到 每次执行时间都超过了1s,因为没走缓存,每次线程都暂停了1s。

    然后我们把那个String比较器删掉。理论上这次调用的就是String的equals方法,就能走上缓存了。

    2016-08-28 23:52:27:418 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,986ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:418 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1020ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:418 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,987ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:418 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1026ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:419 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,0ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:420 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:420 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,2ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:418 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1037ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:420 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,0ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:420 com.zhangwei.learning.utils.digest.DigestInterceptor (TestBean,test,1ms,No Exception,aaa^bbb,aaabbb)
    2016-08-28 23:52:27:421 com.zhangwei.learning.run.MutiThreadRun 任务执行完毕,执行时间1043ms,共有10个任务,执行异常0次

    可以看到,除了5个结果执行时间超过1s,其他的都很快,为啥呢?因为方法是多线程执行的,5个线程,最开始执行,5个线程中一个线程会执行方法,并且把结果放到缓存里面。然后5个线程一起等待方法执行完成然后把结果返回,然后后面的所有的都只需要从缓存获取就好了,这似不似很赞~~~~

  • 相关阅读:
    iview表格render多元素封装
    iview表单语法
    vue实例,extend methods——实例属性 vue和jQuery.js一起使用
    vue.js基础__ extend 扩展选项
    vue.js基础__ mixins 选项
    vue.js基础__ watch 选项
    vue.js基础__ methods 选项
    vue.js基础__ computed 选项
    vue.js基础__ propsData 选项
    vue-cli, webpack + vue 目录结构解读
  • 原文地址:https://www.cnblogs.com/color-my-life/p/5801411.html
Copyright © 2011-2022 走看看