首页 > 编程语言 > 详细

Spring Cache

时间:2018-01-25 22:51:56      阅读:253      评论:0      收藏:0      [点我收藏+]

  缓存是实际工作中非常常用的一种提高性能的方法, 我们会在许多场景下来使用缓存。本文通过一个简单的例子进行展开,通过对比我们原来的自定义缓存和 spring 的基于注释的 cache 配置方法,展现了 spring cache 的强大之处,然后介绍了其基本的原理,扩展点和使用场景的限制。通过阅读本文,你应该可以短时间内掌握 spring 带来的强大缓存技术,在很少的配置下即可给既有代码提供缓存能力。

Spring Cache 介绍

  Spring 3.1 引入了激动人心的基于注释(annotation)的缓存(cache)技术,它本质上不是一个具体的缓存实现方案(例如EHCache 或者 OSCache),而是一个对缓存使用的抽象,通过在既有代码中添加少量它定义的各种 annotation,即能够达到缓存方法的返回对象的效果。从Spring 4.1开始,通过JSR-107注释和更多定制选项的支持,缓存抽象得到了显着改善。

  Spring 的缓存技术还具备相当的灵活性,不仅能够使用 SpEL(Spring Expression Language)来定义缓存的 key 和各种 condition,还提供开箱即用的缓存临时存储方案,也支持和主流的专业缓存例如 EHCache 集成。

其特点总结如下:

  • 通过少量的配置 annotation 注释即可使得既有代码支持缓存
  • 支持开箱即用 Out-Of-The-Box,即不用安装和部署额外第三方组件即可使用缓存
  • 支持 Spring Express Language,能使用对象的任何属性或者方法来定义缓存的 key 和 condition
  • 支持 AspectJ,并通过其实现任何方法的缓存支持
  • 支持自定义 key 和自定义缓存管理者,具有相当的灵活性和扩展性

  本文将针对上述特点对 Spring cache 进行详细的介绍,主要通过一个简单的例子和原理来展开介绍,同时会介绍一些我们平时使用需要特别注意的地方以及使用限制,然后我们将一起看一个比较实际的缓存例子,最后会介绍Spring如何去集成第三方的缓存解决方案。在详细介绍如何使用Spring cache之前,我们不妨先想一下,如果不使用Spring cache,我们来自己实现一个Cache,你会怎么来做呢?下面使我自己的实现方式,看看和你想的是否相同。

自定义Cache实现方式

第一步:创建实体类

 1 package dan.gao.cache;
 2 
 3 import java.io.Serializable;
 4 
 5 /**
 6  * @Auther: dan gao
 7  * @Description:
 8  * @Date: 20:09 2018/1/23 0023
 9  */
10 public class Account implements Serializable {
11 
12     private long id;
13     private String name;
14 
15     public long getId() {
16         return id;
17     }
18 
19     public void setId(long id) {
20         this.id = id;
21     }
22 
23     public String getName() {
24         return name;
25     }
26 
27     public void setName(String name) {
28         this.name = name;
29     }
30 }

第二步: 创建服务接口

 1 package dan.gao.cache;
 2 
 3 import java.util.List;
 4 
 5 /**
 6  * @Auther: dan gao
 7  * @Description:
 8  * @Date: 20:15 2018/1/23 0023
 9  */
10 public interface AccountService {
11 
12     Account getAccountById();
13 
14     Account UpdateAccount(final Account account);
15 
16     void deleteAccountById();
17 
18     List<Account> getAccountsByName();
19 }

第三步:创建默认的服务类

 1 package dan.gao.cache.impl;
 2 
 3 import dan.gao.cache.Account;
 4 import dan.gao.cache.AccountService;
 5 import org.slf4j.Logger;
 6 import org.slf4j.LoggerFactory;
 7 import org.springframework.stereotype.Service;
 8 
 9 import java.util.Arrays;
10 import java.util.List;
11 
12 /**
13  * @Auther: dan gao
14  * @Description:
15  * @Date: 20:21 2018/1/23 0023
16  */
17 @Service
18 public class DefaultAccountService implements AccountService {
19 
20     private final Logger logger = LoggerFactory.getLogger(DefaultAccountService.class);
21 
22     public Account getAccountById(long id) {
23         logger.info("real querying db {..}", id);
24         return new Account(id);
25     }
26 
27     public Account UpdateAccount(Account account) {
28         logger.info("update account to db");
29         return account;
30     }
31 
32     public void deleteAccountById(long id) {
33         logger.info("delete account from db");
34     }
35 
36     public List<Account> getAccountsByName(String name) {
37         logger.info("querying from db", name);
38         return Arrays.asList(new Account(name));
39     }
40 }

第四步:创建缓存管理器

 1 package dan.gao.cache;
 2 
 3 import java.util.Map;
 4 import java.util.concurrent.ConcurrentHashMap;
 5 
 6 /**
 7  * @Auther: dan gao
 8  * @Description:
 9  * @Date: 21:37 2018/1/23 0023
10  */
11 public class CacheManagement<T> {
12 
13     private Map<String, T> cache = new ConcurrentHashMap<String, T>();
14 
15     public T get(final String key) {
16         return cache.get(key);
17     }
18 
19     public void put(final String key, final T value) {
20         cache.put(key, value);
21     }
22 
23     public void evict(final System key) {
24         if(cache.containsKey(key)){
25             cache.remove(key);
26         }
27     }
28 
29     public void reload(){
30         cache.clear();
31     }
32 }

第五步:创建服务代理类

 1 package dan.gao.cache.impl;
 2 
 3 import dan.gao.cache.Account;
 4 import dan.gao.cache.AccountService;
 5 import dan.gao.cache.CacheManagement;
 6 import org.slf4j.Logger;
 7 import org.slf4j.LoggerFactory;
 8 import org.springframework.stereotype.Service;
 9 
10 import javax.annotation.Resource;
11 import java.util.List;
12 
13 /**
14  * @Auther: dan gao
15  * @Description:
16  * @Date: 21:50 2018/1/23 0023
17  */
18 @Service("defaultAccountServiceProxy")
19 public class DefaultAccountServiceProxy implements AccountService {
20 
21     private Logger logger = LoggerFactory.getLogger(DefaultAccountServiceProxy.class);
22 
23     @Resource(name = "defaultAccountService")
24     private AccountService accountService;
25 
26     @Resource
27     private CacheManagement<Account> cacheManagement;
28 
29     public Account getAccountById(long id) {
30         Account account = cacheManagement.get(String.valueOf(id));
31         if (account == null) {
32             Account account1 = accountService.getAccountById(id);
33             cacheManagement.put(String.valueOf(account1.getId()), account1);
34             return account1;
35         } else {
36             logger.info("get account from cache");
37             return account;
38         }
39     }
40 
41     public Account UpdateAccount(Account account) {
42         Account account1 = accountService.UpdateAccount(account);
43         logger.info("update cache value");
44         cacheManagement.put(String.valueOf(account1.getId()), account1);
45         return account1;
46     }
47 
48     public void deleteAccountById(long id) {
49         accountService.deleteAccountById(id);
50         logger.info("delete from cache");
51         cacheManagement.evict(String.valueOf(id));
52     }
53 
54     public List<Account> getAccountsByName(String name) {
55         return null;
56     }
57 
58 }

第六步:创建测试类

 1 package dan.gao.test;
 2 
 3 import dan.gao.cache.Account;
 4 import dan.gao.cache.AccountService;
 5 import org.junit.Before;
 6 import org.junit.Test;
 7 import org.slf4j.Logger;
 8 import org.slf4j.LoggerFactory;
 9 import org.springframework.context.support.ClassPathXmlApplicationContext;
10 
11 import static org.junit.Assert.assertNotNull;
12 
13 /**
14  * @Auther: dan gao
15  * @Description:
16  * @Date: 22:14 2018/1/23 0023
17  */
18 public class CacheTest {
19 
20     private final Logger logger = LoggerFactory.getLogger(CacheTest.class);
21 
22     private AccountService accountService;
23 
24     @Before
25     public void setUp() {
26         ClassPathXmlApplicationContext classPathXmlApplicationContext =
27                 new ClassPathXmlApplicationContext("applicationContext.xml");
28         accountService = classPathXmlApplicationContext.getBean("defaultAccountServiceProxy", AccountService.class);
29     }
30 
31     @Test
32     public void testInject() {
33         assertNotNull(accountService);
34     }
35 
36     @Test
37     public void testCache() {
38         accountService.getAccountById(1);
39         accountService.getAccountById(1);
40         accountService.deleteAccountById(1);
41         accountService.getAccountById(1);
42         accountService.getAccountById(1);
43         Account account = new Account(2);
44         accountService.UpdateAccount(account);
45         accountService.getAccountById(2);
46     }
47 }

测试结果如下,完全按照了我们之前预测的那样,这样我就实现了一个简单的Cache功能,接下来们来看看Spring给我们提供的Cache功能。

DefaultAccountService - real querying db {..}
 DefaultAccountServiceProxy - get account from cache
 DefaultAccountService - delete account from db
 DefaultAccountServiceProxy - delete from cache
 DefaultAccountService - real querying db {..}
 DefaultAccountServiceProxy - get account from cache
 DefaultAccountService - update account to db
 DefaultAccountServiceProxy - update cache value
 DefaultAccountServiceProxy - get account from cache

通过Spirng Cache重新来实现上述的功能

第一步:重写服务实体类

 1 package dan.gao.cache.impl;
 2 
 3 import dan.gao.cache.Account;
 4 import dan.gao.cache.AccountService;
 5 import org.slf4j.Logger;
 6 import org.slf4j.LoggerFactory;
 7 import org.springframework.cache.annotation.CacheEvict;
 8 import org.springframework.cache.annotation.CachePut;
 9 import org.springframework.cache.annotation.Cacheable;
10 import org.springframework.stereotype.Service;
11 
12 import java.util.Arrays;
13 import java.util.List;
14 
15 /**
16  * @Auther: dan gao
17  * @Description:
18  * @Date: 20:21 2018/1/23 0023
19  */
20 @Service("defaultAccountService")
21 public class DefaultAccountService implements AccountService {
22 
23     private final Logger logger = LoggerFactory.getLogger(DefaultAccountService.class);
24 
25     @Cacheable(value = "accounts", key = "#p0")
26     public Account getAccountById(long id) {
27         logger.info("real querying db {..}", id);
28         return new Account(id);
29     }
30 
31     @CachePut(cacheNames="accounts", key="#p0.getId()")
32     public Account UpdateAccount(Account account) {
33         logger.info("update account to db");
34         return account;
35     }
36 
37     @CacheEvict(cacheNames="accounts", key = "#p0")
38     public void deleteAccountById(long id) {
39         logger.info("delete account from db");
40     }
41 
42     public List<Account> getAccountsByName(String name) {
43         logger.info("querying from db", name);
44         return Arrays.asList(new Account(name));
45     }
46 }

第二步:写测试类

package dan.gao.test;

import dan.gao.cache.Account;
import dan.gao.cache.AccountService;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.ClassPathXmlApplicationContext;

import static org.junit.Assert.assertNotNull;

/**
 * @Auther: dan gao
 * @Description:
 * @Date: 22:14 2018/1/23 0023
 */
public class CacheTest {

    private final Logger logger = LoggerFactory.getLogger(CacheTest.class);

    private AccountService accountService;

    private AccountService spirngCacheAccountService;

    @Before
    public void setUp() {
        ClassPathXmlApplicationContext classPathXmlApplicationContext =
                new ClassPathXmlApplicationContext("applicationContext.xml");
        accountService = classPathXmlApplicationContext.getBean("defaultAccountServiceProxy", AccountService.class);

        spirngCacheAccountService = classPathXmlApplicationContext.getBean("defaultAccountService", AccountService.class);
    }

    @Test
    public void testInject() {
        assertNotNull(accountService);
    }

    @Test
    public void testCache() {
        accountService.getAccountById(1);
        accountService.getAccountById(1);
        accountService.deleteAccountById(1);
        accountService.getAccountById(1);
        accountService.getAccountById(1);
        Account account = new Account(2);
        accountService.UpdateAccount(account);
        accountService.getAccountById(2);
    }

    @Test
    public void testSpringCache(){
        spirngCacheAccountService.getAccountById(1);
        logger.info("#########");
        spirngCacheAccountService.getAccountById(1);
        logger.info("#########");
        spirngCacheAccountService.deleteAccountById(1);
        logger.info("#########");
        spirngCacheAccountService.getAccountById(1);
        logger.info("#########");
        spirngCacheAccountService.getAccountById(1);
        logger.info("#########");
        Account account = new Account(2);
        spirngCacheAccountService.UpdateAccount(account);
        logger.info("#########");
        spirngCacheAccountService.getAccountById(2);
        logger.info("#########");
    }

}

运行结果如下,我们明显得可以看出我们的Cache已经成功运行。 通过Spring Cache注解的方式我们可以很轻松的给原有的方法加上缓存的功能,不需要写任何的代码,看起来是不是很酷。接下来我将详细介绍之前在服务类中使用的注解,让我们可以在项目中更加灵活的使用它们。

DefaultAccountService - real querying db {..}
 CacheTest - #########
 CacheTest - #########
 DefaultAccountService - delete account from db
 CacheTest - #########
 DefaultAccountService - real querying db {..}
 CacheTest - #########
 CacheTest - #########
 DefaultAccountService - update account to db
 CacheTest - #########
 CacheTest - #########

1. @Cacheable annotation

  从上面的测试结果我们明显的看出来使用了@Cacheable注解的方法,当第一次请求过之后,第二次再使用相同的参数调用时,方法就不会再执行了。这是因为当我们第一次请求之后,spring cache会将执行结果与我们定义的Key以key-value的形式存储起来,第二次请求时会先用key在缓存中找,看能不能找到结果。如果找到的话,就不会在执行方法体,直接返回缓存中的结果。如果没有找,就会执行方法体,在方法执行完成之后,再将执行结果与key保存到缓存里边。在我们的代码中getAccountById方法与名为acounts的缓存相关联。 每次调用该方法时,都会检查缓存以查看调用是否已经执行并且不必重复。 在大多数情况下,只声明一个缓存,注释允许指定多个名称,以便使用多个缓存。 在这种情况下,将在执行方法之前检查每个缓存 - 如果至少有一个缓存被命中,则返回相关的值:

@Cacheable(cacheNames = {"accounts", "default"}, key = "#p0")
public Account getAccountById(long id) {..}

 在这一有一点我们需要注意,即使缓存的方法没有被执行,但是其他不包含该值的缓存也会被更新。

 由于缓存本质上是键值存储,所以缓存方法的每次调用都需要被转换为适合缓存访问的密钥。Spring cache默认的key基于以下算法的简单KeyGenerator:

  • 如果没有给出参数,则返回SimpleKey.EMPTY。
  • 如果只给出一个参数,则返回该实例。
  • 如果给出了超过一个参数,返回一个包含所有参数的SimpleKey。

  这种方法适用于大多数使用情况; 只要参数具有自然键并实现有效的hashCode()和equals()方法。 如果情况并非如此,那么战略就需要改变。为了提供不同的默认密钥生成器,需要实现org.springframework.cache.interceptor.KeyGenerator接口。@Cacheable注解允许用户指定如何通过其关键属性来生成密钥。 开发人员可以使用SpEL来选择感兴趣的参数(或它们的嵌套属性),执行操作,甚至调用任意方法,而不必编写任何代码或实现任何接口。 这是默认生成器的推荐方法,因为方法与代码库增长的方式在签名方面会有很大差异; 而默认策略可能适用于某些方法,但对于所有方法都很少。下面是各种SpEL声明的一些例子 - 如果你不熟悉它,请阅读Spring Expression Language

@Cacheable(cacheNames="books", key="#isbn")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="#isbn.rawNumber")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

@Cacheable(cacheNames="books", key="T(someType).hash(#isbn)")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

上面的代码片断展示了选择某个参数,它的一个属性,甚至是一个任意的(静态)方法特别的便捷。如果负责生成密钥的算法过于具体,或者需要共享,则可以在操作中定义一个自定义密钥生成器。 为此,请指定要使用的KeyGenerator bean实现的名称:

@Cacheable(cacheNames="books", keyGenerator="myKeyGenerator")
public Book findBook(ISBN isbn, boolean checkWarehouse, boolean includeUsed)

但在这个我们需要注意的是key和keyGenerator我们同时只能指定一个,否则将会产生操作异常。

  在多线程环境中,某些操作可能会同时调用相同的参数(通常在启动时)。 默认情况下,缓存抽象不会锁定任何内容,并且可能会多次计算相同的值,从而导致缓存失效。对于这些特定情况,可以使用sync属性指示基础缓存提供者在计算值时锁定缓存条目。 结果,只有一个线程忙于计算值,而其他线程被阻塞,直到在高速缓存中更新条目。

@Cacheable(cacheNames="foos", sync=true)
public Foo executeExpensiveOperation(String id) {...}

但是这是一个可选的功能,你选择的缓存库可能并不支持这个功能,核心框架提供的所有CacheManager实现都支持它。 请查看你选择的缓存库的文档查看详细信息。

  有时候,一个方法可能并不适合于所有的缓存(例如,它可能取决于给定的参数)。缓存注释通过条件参数来支持这样的功能,该条件参数采用被评估为真或假的SpEL表达式。如果为true,则该方法被缓存 - 如果不是,则其行为就像该方法没有被缓存,每当无论缓存中的值或使用什么参数时都执行该方法。一个简单的例子 - 只有参数名称的长度小于32时才会缓存以下方法:

@Cacheable(cacheNames="book", condition="#name.length() < 32")
public Book findBook(String name)

另外还可以使用条件参数unless来否决向缓存添加值。 与condition不同,unless在方法被调用之后评估表达式。 扩展前面的例子 - 也许我们只想缓存paperback books:

@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result.hardback")
public Book findBook(String name)

上面的例子我们直接是用了result.hardback,如果result返回为null呢?我们上边的写法并不安全,为了结果这个问题,我们可以向下边这样来写:

@Cacheable(cacheNames="book", condition="#name.length() < 32", unless="#result?.hardback")
public Optional<Book> findBook(String name)

每个SpEL表达式都会重新声明一个专用的上下文。 除了内置参数外,框架还提供了与参数名称相关的专用缓存相关元数据。 详细信息请查看Available caching SpEL evaluation context

2. @CachePut annotation 

  对于需要在不干扰方法执行的情况下更新缓存的情况,可以使用@CachePut注解。 也就是说,这个方法总是被执行,并且它的结果放入缓存。 它支持与@Cacheable相同的选项,应该用于缓存填充而不是方法流程优化:

@CachePut(cacheNames="accounts", key="#p0.getId()")
public Account UpdateAccount(Account account)

 

  请注意,在同一个方法上使用@CachePut和@Cacheable注解通常是冲突的,因为它们有不同的行为。 后者导致使用缓存跳过方法执行,前者强制执行以执行缓存更新。 这会导致意想不到的行为,除了特定的角落案例(例如具有排除他们的条件的注释)之外,应该避免这种声明。 还要注意,这样的条件不应该依赖于结果对象(即#result变量),因为它们是预先验证的以确认排除。

3. @CacheEvict annotation

  Spring Cache不仅允许缓存存储,而且还可以删除。此过程对于从缓存中删除过时或未使用的数据很有用。与@Cacheable相反,注解@CacheEvict划定了执行缓存删除的方法,即充当从缓存中移除数据的触发器的方法。就像其兄弟一样,@CacheEvict需要指定一个(或多个)受操作影响的缓存,允许指定自定义的缓存和密钥解析或条件,但另外还有一个额外的参数allEntries,来判断是否删除缓存中的所有数据而不仅仅只是一条数据:

@CacheEvict(cacheNames="books", allEntries=true)
public void loadBooks(InputStream batch)

 

  当需要清除整个缓存区域时,这个选项会派上用场,而不是逐条清除每个条目(这会耗费很长的时间,因为效率很低),所有的条目在一个操作中被删除,如上所示。请注意,该框架将忽略在此方案中指定的任何key,因为它不适用(删除整个缓存不仅仅是一个条目)。我们还可以通过也beforeInvocation属性来指定缓存清除的时间。beforeInvocation=true表明不管方法有没有被成功执行都先清除缓存。beforeInvocation=false则表明只有当方法成功执行之后,才进行缓存清理动作。默认beforeInvocation=false。需要注意的是void方法可以和@CacheEvict一起使用 - 由于这些方法充当触发器,所以返回值被忽略(因为它们不与缓存交互) - @Cacheable并不是这种情况,它添加/将数据更新到缓存中并因此需要结果。

4. @Caching annotation

  在某些情况下,需要指定相同类型的多个注释(例如@CacheEvict或@CachePut),例如因为条件或密钥表达式在不同的缓存之间是不同的。 @Caching允许在同一个方法上使用多个嵌套的@Cacheable,@CachePut和@CacheEvict:

@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames="secondary", key="#p0") })
public Book importBooks(String deposit, Date date)

 

上面的代码表示当方法被成功执行之后会删除缓存primary中的所有数据,同时删除缓存池secondary中key=传入参数deposit的那一条数据。

5. @CacheConfig annotation

  到目前为止,我们已经看到,缓存操作提供了许多定制选项,可以在操作基础上进行设置。 但是,如果某些自定义选项适用于该类的所有操作,则可能会进行繁琐的配置。 例如,指定要用于该类的每个缓存操作的缓存的名称可以由单个类级别的定义替换。 这是@CacheConfig使用的地方:

@CacheConfig("books")
public class BookRepositoryImpl implements BookRepository {

    @Cacheable
    public Book findBook(ISBN isbn) {...}
}

 

@CacheConfig是一个类级注释,允许共享缓存名称,自定义KeyGenerator,自定义CacheManager和自定义CacheResolver。 将该注释放在类上不会启用任何缓存操作。在方法上定制缓存配置将始终覆盖@CacheConfig上的定制设置。

6. 开启缓存功能

  需要注意的是,即使声明缓存注释并不会自动触发它们的动作 - 就像Spring中的许多事情一样,这个功能必须被声明性地启用(这意味着如果你怀疑是缓存引起的问题的话,你可以通过删除一个配置行,而不是代码中的所有注释,来关闭缓存)。要启用缓存注释,请将注释@EnableCaching添加到您的@Configuration类之一中:

@Configuration
@EnableCaching
public class AppConfig {
}

 

或者使用Spring配置文件来进行开启

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:cache="http://www.springframework.org/schema/cache"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/cache
        http://www.springframework.org/schema/cache/spring-cache.xsd">
    <context:component-scan base-package="dan.gao"/>
    <cache:annotation-driven />
    <bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager">
        <property name="caches">
            <set>
                <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean">
                    <property name="name" value="default"/>
                </bean>
                <bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean">
                    <property name="name" value="accounts"/>
                </bean>
            </set>
        </property>
    </bean>
</beans>

 

在上面的配置中<cache:annotation-driven />表示开启缓存。因为Spring cache是只是提供了一个抽象的缓存管理,因此我们还需要创建cacheManager对象和Cahce对象。在本次的例子中我是用了Spring默认给我们提供的SimpleCacheManager和ConcurrentMapCacheFactoryBean, 并且创建了两个缓存池default和accounts.

  直到现在,我们已经学会了如何使用开箱即用的 spring cache,这基本能够满足一般应用对缓存的需求。但现实总是很复杂,当你的用户量上去或者性能跟不上,总需要进行扩展,这个时候你或许对其提供的内存缓存不满意了,因为其不支持高可用性,也不具备持久化数据能力,这个时候,你就需要自定义你的缓存方案了。

Spring Cache

原文:https://www.cnblogs.com/daniels/p/8331509.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!