Redis API & Java RedisTemplate深入分析

Redis API

Redis是一種基于鍵值對的NoSQL數(shù)據(jù)庫。

在展開Redis API之前作為開發(fā)者的我們無論在用什么樣的編程語言府蛇,開發(fā)什么樣的項目都會有使用到將數(shù)據(jù)緩存在內(nèi)存中的場景璃饱。

如果讓我們自己開設(shè)計并開發(fā)一款基于鍵值對的緩存數(shù)據(jù)庫我們該如何實現(xiàn)彼哼?

支持哪些數(shù)據(jù)結(jié)構(gòu)涯肩?

  • 作為java coder的筆者就經(jīng)常遇到需要將配置信息、熱點高頻數(shù)據(jù)芭概、統(tǒng)計數(shù)據(jù)、高性能需求數(shù)據(jù)緩存到String惩嘉、List罢洲、Map等數(shù)據(jù)結(jié)構(gòu)的需求。

在緩存數(shù)據(jù)時需要根據(jù)需求選擇合適的數(shù)據(jù)結(jié)構(gòu),Redis中提供了5種基本的數(shù)據(jù)結(jié)構(gòu)惹苗。

  • string
  • hash
  • list
  • set
  • zset

string

字符串是Redis中最基本的數(shù)據(jù)結(jié)構(gòu)殿较。Redis中的健都是以字符串進行存儲的。字符串可以是簡單字符串桩蓉、復(fù)雜字符串(JSON淋纲、XML)、數(shù)字(整形院究、浮點型)洽瞬、二進制(圖片、音頻业汰、視屏)伙窃,其最大值不能超過512MB。

字符串的使用場景很多可以將對象轉(zhuǎn)換成json字符串存儲在Redis中样漆。在分布式web服務(wù)器中也可以使用字符串存儲用戶session为障,保證請求在路由到新機器時能夠識別用戶身份無需二次登錄。

當(dāng)字符串存儲數(shù)字時可以實現(xiàn)高性能分布式線程安全的的快速計數(shù)(類似Java中的AtomicLong#incrementAndGet 需要使用CAS實現(xiàn)線程安全放祟,而Redis天生的單線程模型使其簡單高效地實現(xiàn)了計數(shù)功能)鳍怨,實現(xiàn)很多統(tǒng)計功能。

hash

field value
id 1
name 小明
age 19
birthday 1999-09-09

哈希類型可以看做java中的map類型舞竿。在Redis中的哈希類型的映射關(guān)系為field-value不是健對應(yīng)的值京景,需要注意value不同的上下文。

哈掀保可以存儲關(guān)系型數(shù)據(jù)庫表中的字段和值确徙,如可以將用戶信息存儲在hash

list

列表類型用于存儲多個有序的字符串。在Redis中可以對列表的兩端進行插入和彈出(類似java中的Deque雙端列隊)执桌,也可以像數(shù)組一樣通過下標獲取對應(yīng)的值鄙皇。雙向鏈表的結(jié)構(gòu)使其既可以充當(dāng)棧也可以充當(dāng)列隊的角色。

可以使用其從左邊push右邊pop的特性實現(xiàn)消息列隊仰挣,比如注冊成功后的郵件通知使用Redis消息列隊相比于MQ中間件將更加輕量易于維護伴逸。

使用列表的有序性以及可以按下標和范圍查找的特性緩存數(shù)據(jù)庫中的需要分頁顯示的列表數(shù)據(jù)。

微信朋友圈的動態(tài)就可以使用list進行實現(xiàn)膘壶,每當(dāng)有好友發(fā)布動態(tài)時就向list中存儲動態(tài)的id错蝴。其有序性保證了時間軸的實現(xiàn)。

set

集合用來保存多個不同的字符串元素颓芭。和java中的set一樣顷锰,集合是無序的不能用下標進行訪問,集合的唯一性可以用來存儲標簽系統(tǒng)中的tag如用戶的興趣愛好或是新聞系統(tǒng)中用戶關(guān)注的欄目等亡问。

Redis中的集合類型NB的地方在于除了基本的增刪改查操作外還支持集合間的交集官紫、并集、差集運算,這種特性將非常方便地解決了社交網(wǎng)絡(luò)應(yīng)用中的很多需求束世,如共同關(guān)注酝陈、共同喜好、二度好友等功能毁涉。

此外Redis提供隨機獲取集合中元素的api可以用于生成隨機數(shù)的業(yè)務(wù)中如抽獎系統(tǒng)等沉帮。

zset

有序集合是在集合的基礎(chǔ)上為每個元素設(shè)置分數(shù)(score)作為排序依據(jù)。有序集合增加了獲取指定分數(shù)的元素和元素范圍查找薪丁、計算成員排名功能遇西。

有序集合可以用在社交和游戲中的排行榜需求中。

如何存儲數(shù)據(jù)(內(nèi)部編碼)严嗜?

確定了支持的數(shù)據(jù)結(jié)構(gòu)后我們需要設(shè)計合理的編碼(存儲的大小和查詢的時間復(fù)雜度)方式將不同數(shù)據(jù)結(jié)構(gòu)的數(shù)據(jù)編碼成二進制數(shù)據(jù)存儲在內(nèi)存中粱檀。

在Redis中不同的數(shù)據(jù)結(jié)構(gòu)都有多種內(nèi)部編碼方式,在使用時需要根據(jù)實際的情況選擇合適的編碼以達到時間和空間的平衡漫玄。

內(nèi)部編碼.png

如何操作數(shù)據(jù)(命令茄蚯、協(xié)議)?

我們還需要設(shè)計對外開放的api 供外部系統(tǒng)訪問數(shù)據(jù)睦优。

redis-cli

Redis提供了redis-cli 可以在命令行操作數(shù)據(jù)
Redis是一種基于鍵值對的NoSQL數(shù)據(jù)庫渗常,它的5種數(shù)據(jù)結(jié)構(gòu)都是健值對中的值。對于健來說有一些通用命令汗盘。

命令 描述
keys * 查看所有健
dbsize 健總數(shù)
exists key 檢查健是否存在
del key [key ...] 刪除鍵
expire key seconds 鍵過期
type key 鍵的數(shù)據(jù)結(jié)構(gòu)類型
object encoding key 值的內(nèi)部編碼

后端開發(fā)的同學(xué)們在接觸Redis之前肯定學(xué)過至少一種關(guān)系型數(shù)據(jù)庫皱碘,下面以關(guān)系型數(shù)據(jù)庫的增、刪隐孽、改癌椿、查來總結(jié)Redis中不同數(shù)據(jù)結(jié)構(gòu)的操作命令

string set key value [ex seconds] [px milliseconds] [nx|xx] ex seconds:為健設(shè)置秒級過期時間 px milliseconds:為健設(shè)置毫秒級過期時間 nx : 健必須不存在才能成功,用于新增 xx:健必須存在才能成功菱阵,用于更新 del key 同增 get key
hash hset key field value hdel key field 同增 hget key field
list rpush key value [value ...] lpush key value [value ...] linsert key before|after piovt value lpop key rpop key lrem count value ltrim key start end lset key index value lrange key start end lindex key index lllen key
set sadd key element [element ...] srem key element [element ...] 同增 scard key sismember key element srandmember key [count] spop key smenbers key
zset zadd key score member [score member ...] zrem key member 同增 zcard key zscore key member zrank key member zrevrank key member

redis 的命令有很多無需把每個命令都背下來只需要牢記5種數(shù)據(jù)結(jié)構(gòu)的特性再根據(jù)實際的需求去尋找需要的命令就OK了踢俄。
更多命令見Redis命令參考

客戶端通信協(xié)議RESP

Redis 定制了RESP協(xié)議實現(xiàn)客戶端與服務(wù)端的正常交互。這是一種基于TCP協(xié)議之上簡單高效的協(xié)議晴及。

客戶端請求
如需要在Redis 中存儲鍵值對為hello-world 的數(shù)據(jù)都办,需要在客戶端發(fā)送如下格式的數(shù)據(jù)(每行使用/r/n進行分割)給Redis服務(wù)器。

*3
$3
SET
$5
hello
$5
world

協(xié)議說明:
*3表示參數(shù)量為3個即本條命令有3個參數(shù)
35 $5表示參數(shù)的字節(jié)數(shù)虑稼。
上面的數(shù)據(jù)進行了格式化的顯示琳钉,實際傳輸?shù)母袷綖槿缦麓a

*3/r/n$3/r/nSET/r/n$5/r/nhello/r/n$5/r/nworld/r/n

Redis服務(wù)端響應(yīng)
Redis服務(wù)器收到指令正確解析后返回如下數(shù)據(jù)

+OK

協(xié)議說明:
狀態(tài)回復(fù):+
錯誤回復(fù):-
整數(shù)回復(fù)::
字符串回復(fù):$
多條字符串回復(fù):*

Redis客戶端

Jedis

jedis實現(xiàn)了RESP協(xié)議。

獲取jedis

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
    <type>jar</type>
    <scope>compile</scope>
</dependency>

Jedis基本使用

// 創(chuàng)建連接
Jedis jedis = new Jedis("localhost", 6379);
// 存儲數(shù)據(jù)
jedis.set("foo", "bar");
// 獲取數(shù)據(jù)
String value = jedis.get("foo");

Jedis連接池的使用

JedisPool pool = new JedisPool(new JedisPoolConfig(), "localhost", 6379);
/// Jedis implements Closeable. Hence, the jedis instance will be auto-closed after the last statement.
try (Jedis jedis = pool.getResource()) {
    /// ... do stuff here ... for example
    jedis.set("foo", "bar");
    String foobar = jedis.get("foo");
    jedis.zadd("sose", 0, "car"); jedis.zadd("sose", 0, "bike");
    Set<String> sose = jedis.zrange("sose", 0, -1);
    System.out.print(sose);
}

Spring RedisTemplate

RedisTemplate基本使用

在理解RedisTemplate背后的原理前我們先看看其是如何操作Redis的蛛倦。

...

@Autowired
private StringRedisTemplate stringRedisTemplate;

@Test
public void test() throws Exception {
    // 保存字符串
    stringRedisTemplate.opsForValue().set("aaa", "111");
    Assert.assertEquals("111", stringRedisTemplate.opsForValue().get("aaa"));
}

...

我們需要向健aaa設(shè)置value為111需要先獲取ValueOperations對象然后進行相關(guān)命令操作歌懒。

RedisTemplate源碼分析

針對Redis的支持的數(shù)據(jù)結(jié)構(gòu),從RedisTemplate源碼中可知使用如下類封裝了相關(guān)數(shù)據(jù)結(jié)構(gòu)的命令

  • ValueOperations (string)
  • ListOperations (list)
  • SetOperations (set)
  • ZSetOperations (zset)
  • GeoOperations (GEO)
  • HyperLogLogOperations (HyperLogLog)
public class RedisTemplate<K, V> extends RedisAccessor implements RedisOperations<K, V>, BeanClassLoaderAware {

   ...
   
   private @Nullable ValueOperations<K, V> valueOps;
   private @Nullable ListOperations<K, V> listOps;
   private @Nullable SetOperations<K, V> setOps;
   private @Nullable ZSetOperations<K, V> zSetOps;
   private @Nullable GeoOperations<K, V> geoOps;
   private @Nullable HyperLogLogOperations<K, V> hllOps;
   
   ...
 
 }

在"RedisTemplate基本使用"所示的例子中通過spring的注入注解獲取了StringRedisTemplate對象的引用胰蝠。

@Autowired
private StringRedisTemplate stringRedisTemplate;

StringRedisTemplate又是如何被初始化的呢?
我們找到springboot 源碼中的RedisAutoConfiguration如下所示

@Configuration
@ConditionalOnClass(RedisOperations.class)
@EnableConfigurationProperties(RedisProperties.class)
@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class })
public class RedisAutoConfiguration {

   @Bean
   @ConditionalOnMissingBean(name = "redisTemplate")
   public RedisTemplate<Object, Object> redisTemplate(
         RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
      RedisTemplate<Object, Object> template = new RedisTemplate<>();
      template.setConnectionFactory(redisConnectionFactory);
      return template;
   }

   @Bean
   @ConditionalOnMissingBean
   public StringRedisTemplate stringRedisTemplate(
         RedisConnectionFactory redisConnectionFactory) throws UnknownHostException {
      StringRedisTemplate template = new StringRedisTemplate();
      template.setConnectionFactory(redisConnectionFactory);
      return template;
   }

}

在RedisAutoConfiguration了初始化了RedisTemplate<Object, Object> 和 StringRedisTemplate對象,他們都依賴的一個參數(shù) redisConnectionFactory茸塞,

redisConnectionFactory又是如何創(chuàng)建的呢躲庄?

通過IDE(intelliJ IDEA是真好用_)可以看到RedisConnectionFactory有兩個實現(xiàn)

redisConnectionFactory實現(xiàn)

查看RedisConnectionFactory、JedisConnectionFactory钾虐、LettuceConnectionFactory類可知這邊使用抽象工廠模式噪窘。

基于springboot是插拔式開箱即用特性我猜測這邊肯定有地方注入了連接工廠。
在RedisAutoConfiguration類所在的包下找到了JedisConnectionConfiguration效扫、LettuceConnectionConfiguration

@Configuration
@ConditionalOnClass({ GenericObjectPool.class, JedisConnection.class, Jedis.class })
class JedisConnectionConfiguration extends RedisConnectionConfiguration {

...

@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public JedisConnectionFactory redisConnectionFactory() throws UnknownHostException {
   return createJedisConnectionFactory();
}

...
}
@Configuration
@ConditionalOnClass(RedisClient.class)
class LettuceConnectionConfiguration extends RedisConnectionConfiguration {

...
@Bean
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public LettuceConnectionFactory redisConnectionFactory(
      ClientResources clientResources) throws UnknownHostException {
   LettuceClientConfiguration clientConfig = getLettuceClientConfiguration(
         clientResources, this.properties.getLettuce().getPool());
   return createLettuceConnectionFactory(clientConfig);
}
...

}

根據(jù)spring的java 注解自動配置@ConditionalOnClass 發(fā)現(xiàn)在最新的springboot2.0中已經(jīng)移除了jdeis默認集成了Lettuce(另一種redis java客戶端的實現(xiàn))倔监。
所以在自動裝配時會使用lettuce作為其連接底層耍攘,通過debug發(fā)現(xiàn)確實如此

springboot2.0默認裝配的Redis連接工廠

再回到StringRedisTemplate的父類RedisTemplate<K, V>這個類目代,其中K帮匾、V是泛型實現(xiàn)參考springboot源碼中RedisAutoConfiguration默認自動配置實現(xiàn)瞻离,我們可以擴展自己需要的類型如key存儲string兰伤,對象轉(zhuǎn)成成json string 在redis中存儲配置如下代碼所示:

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisJsonTemplate(RedisConnectionFactory jedisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory);

        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();

        @SuppressWarnings("all")
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);

        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);

        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

}

ValueOperations献汗、ListOperations等是如何調(diào)用jedis或者lettuce 的api的暇赤?

跟蹤stringRedisTemplate.opsForValue().set("aaa", "111");中的set方法

@Nullable
<T> T execute(RedisCallback<T> callback, boolean b) {
   return template.execute(callback, b);
}

@Override
public void set(K key, V value) {

   byte[] rawValue = rawValue(value);
   execute(new ValueDeserializingRedisCallback(key) {

      @Override
      protected byte[] inRedis(byte[] rawKey, RedisConnection connection) {
         connection.set(rawKey, rawValue);
         return null;
      }
   }, true);
}

會統(tǒng)一走execute(命令模式)方法完成操作耙蔑,注入匿名內(nèi)部類創(chuàng)建的回調(diào)對象用于獲取連接后執(zhí)行具體的指令摹迷。
查看execute實現(xiàn)可知最終回到RedisTemplate類中的execute執(zhí)行相關(guān)操作疟赊。

RedisTemplate類中execute方法如下:

@Nullable
public <T> T execute(RedisCallback<T> action, boolean exposeConnection, boolean pipeline) {

   Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it");
   Assert.notNull(action, "Callback object must not be null");
   // 獲取連接工廠
   RedisConnectionFactory factory = getRequiredConnectionFactory();
   RedisConnection conn = null;
   try {
      // 獲取連接  
      if (enableTransactionSupport) {
         // only bind resources in case of potential transaction synchronization
         conn = RedisConnectionUtils.bindConnection(factory, enableTransactionSupport);
      } else {
         conn = RedisConnectionUtils.getConnection(factory);
      }

      boolean existingConnection = TransactionSynchronizationManager.hasResource(factory);

      RedisConnection connToUse = preProcessConnection(conn, existingConnection);

      boolean pipelineStatus = connToUse.isPipelined();
      if (pipeline && !pipelineStatus) {
         connToUse.openPipeline();
      }

      RedisConnection connToExpose = (exposeConnection ? connToUse : createRedisConnectionProxy(connToUse));
      // 使用傳遞的回調(diào)對象執(zhí)行具體的命令
      T result = action.doInRedis(connToExpose);

      // close pipeline
      if (pipeline && !pipelineStatus) {
         connToUse.closePipeline();
      }

      // TODO: any other connection processing?
      return postProcessResult(result, connToUse, existingConnection);
   } finally {
      // 釋放連接
      RedisConnectionUtils.releaseConnection(conn, factory);
   }
}

其流程可以總結(jié)為

  1. 獲取連接
  2. 執(zhí)行命令(使用裝配的具體Reids客戶端完成相關(guān)命令)
  3. 釋放連接

和前面Jedis連接池的使用流程基本一致。

總結(jié)

RedisTemplate對Redis命令進行了統(tǒng)一的封裝對外具有一致的api 和配置峡碉,內(nèi)部命令操作具體的實現(xiàn)由注入的redis客戶端完成
備注:springboot 1.5 Redis默認使用了jedis 客戶端
springboot 2.0 Redis默認使用了lettuce客戶端 近哟,增加了響應(yīng)式api 的支持,有同學(xué)可能對lettuce不了解這里引用一下lettuce項目github 的wiki 來說明一下

Lettuce is a scalable thread-safe Redis client for synchronous, asynchronous and reactive usage. Multiple threads may share one connection if they avoid blocking and transactional operations such as BLPOP and MULTI/EXEC. Lettuce is built with netty. Supports advanced Redis features such as Sentinel, Cluster, Pipelining, Auto-Reconnect and Redis data models.

感覺非常強大的樣子??
最后附上RedisTemplate 操作Redis的demo
https://github.com/yuanzj/SpringBootRedisDemo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末鲫寄,一起剝皮案震驚了整個濱河市吉执,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌塔拳,老刑警劉巖鼠证,帶你破解...
    沈念sama閱讀 221,198評論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異靠抑,居然都是意外死亡量九,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評論 3 398
  • 文/潘曉璐 我一進店門颂碧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來荠列,“玉大人,你說我怎么就攤上這事载城〖∷疲” “怎么了?”我有些...
    開封第一講書人閱讀 167,643評論 0 360
  • 文/不壞的土叔 我叫張陵诉瓦,是天一觀的道長川队。 經(jīng)常有香客問我力细,道長,這世上最難降的妖魔是什么固额? 我笑而不...
    開封第一講書人閱讀 59,495評論 1 296
  • 正文 為了忘掉前任眠蚂,我火速辦了婚禮,結(jié)果婚禮上斗躏,老公的妹妹穿的比我還像新娘逝慧。我一直安慰自己,他們只是感情好啄糙,可當(dāng)我...
    茶點故事閱讀 68,502評論 6 397
  • 文/花漫 我一把揭開白布笛臣。 她就那樣靜靜地躺著,像睡著了一般隧饼。 火紅的嫁衣襯著肌膚如雪沈堡。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,156評論 1 308
  • 那天桑李,我揣著相機與錄音踱蛀,去河邊找鬼。 笑死贵白,一個胖子當(dāng)著我的面吹牛率拒,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播禁荒,決...
    沈念sama閱讀 40,743評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼猬膨,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了呛伴?” 一聲冷哼從身側(cè)響起勃痴,我...
    開封第一講書人閱讀 39,659評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎热康,沒想到半個月后沛申,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 46,200評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡姐军,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,282評論 3 340
  • 正文 我和宋清朗相戀三年铁材,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片奕锌。...
    茶點故事閱讀 40,424評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡著觉,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出惊暴,到底是詐尸還是另有隱情饼丘,我是刑警寧澤,帶...
    沈念sama閱讀 36,107評論 5 349
  • 正文 年R本政府宣布辽话,位于F島的核電站肄鸽,受9級特大地震影響卫病,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜典徘,卻給世界環(huán)境...
    茶點故事閱讀 41,789評論 3 333
  • 文/蒙蒙 一忽肛、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧烂斋,春花似錦、人聲如沸础废。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽评腺。三九已至帘瞭,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間蒿讥,已是汗流浹背蝶念。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評論 1 271
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留芋绸,地道東北人媒殉。 一個月前我還...
    沈念sama閱讀 48,798評論 3 376
  • 正文 我出身青樓,卻偏偏與公主長得像摔敛,于是被迫代替她去往敵國和親廷蓉。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,435評論 2 359