Go中結構化日志的綜合指南(1)

結構化日志包括定義良好的格式(通常是JSON)生成日志記錄,這為應用程序日志添加了一定程度的組織和一致性,使它們更容易處理。這種日志記錄由鍵-值對組成,它們捕獲關于正在記錄的事件的相關上下文信息,例如嚴重級別、時間戳、源代碼位置、用戶ID或任何其他相關元數據。

本文將深入研究Go中的結構化日志,特別關注最近被接受的旨在將高性能的結構化日志記錄級別引入標準庫的提案。

我們將從Go現有的日志包及其局限性開始,然后通過涵蓋所有最重要的概念來深入研究slog庫。我們還將簡要討論Go生態系統中使用最廣泛的一些結構化日志庫。


Go標準庫日志包

在討論新的結構化日志之前,我們先簡要地研究一下標準庫日志,它提供了一種將日志消息寫入控制臺、文件或任何實現io.Writer接口的類型。下面是在Go中編寫日志消息的最基本方法:

package main

import "log"

func main() {
    log.Println("Hello from Go application!")
}

Output

2023/03/08 11:43:09 Hello from Go application!

以上輸出包含日志消息和本地時區的時間,該時間戳表示生成條目的時間。Println()方法是預配置的全局Logger可訪問的方法之一,它輸出到標準錯誤。其他方法有以下幾種:

log.Print()
log.Printf()
log.Fatal()
log.Fatalf()
log.Fatalln()
log.Panic()
log.Panicf()
log.Panicln()

上面的Fatal方法和Panic方法的區別在于前者在記錄消息后調用os.Exit(1),而后者調用Panic()。
可以通過log.Default()方法獲取默認Logger實例,從而自定義Logger。然后,在返回的Logger上調用相關的方法。下面的例子配置日志寫入標準輸出而不是標準錯誤:

func main() {
    defaultLogger := log.Default()
    defaultLogger.SetOutput(os.Stdout)
    log.Println("Hello from Go application!")
}

你也可以通過log.New()方法創建一個完全自定義的日志實例,該方法如下所示:

func New(out io.Writer, prefix string, flag int) *Logger

第一個參數是Logger生成的日志消息寫入的地方,它可以是任何實現io.Writer接口。第二個參數是添加到每個日志消息前的前綴,而第三個指定了一組常量,用于向每個日志消息添加詳細信息。

package main

import (
    "log"
    "os"
)

func main() {
    logger := log.New(os.Stdout, "", log.LstdFlags)
    logger.Println("Hello from Go application!")
}

上面的Logger實例被配置為打印到標準輸出,并且它使用默認的日志實例初始值。因此,輸出與之前相同。
output

2023/03/08 11:44:17 Hello from Go application!

我們通過向每個日志條目添加應用程序名稱、文件名和行號來進一步定制它。這里還將在時間戳中添加微秒,并記錄UTC時間而不是本地時間:

logger := log.New(
  os.Stderr,
  "MyApplication: ",
  log.Ldate|log.Ltime|log.Lmicroseconds|log.LUTC|log.Lshortfile,
)

output:

MyApplication: 2023/03/08 10:47:12.348478 main.go:14: Hello from Go application!

MyApplication:前綴出現在每個日志條目的開頭,UTC時間現在包括微秒。輸出中還包括文件名和行號,以幫助定位代碼庫中每條日志的來源。

標準log包的局限性

盡管Go中的日志包提供了方便的方式來啟動日志記錄,但由于一些限制,它對于生產環境使用并不理想,例如:

  • 缺少日志級別:日志級別是大多數日志包的主要特性之一,但是Go的日志包中缺少日志級別。所有日志消息都以相同的方式處理,因此很難根據其重要性或嚴重程度對日志消息進行過濾或分離。
  • 不支持結構化日志:Go中的日志包只輸出純文本消息。它不支持結構化日志,其中日志記錄的事件以結構化格式(通常是JSON)表示,隨后可以通過編程方式對其進行解析,便于對日志進行監控、警報、審計、創建儀表盤和其他形式的分析。
  • 無上下文感知日志:日志包不支持上下文感知日志,因此很難將上下文信息(例如請求id、用戶id和其他變量)自動附加到日志消息中。
  • 不支持日志采樣:在高吞吐量應用程序中,日志采樣是減少日志量的有用特性。第三方日志庫通常提供這種功能,但是Go的內置日志包中沒有這種功能。
  • 配置項有限:標準日志包只支持基本的配置項,如設置日志輸出的目的地和前綴。高級日志庫提供了更多配置機會,例如自定義日志格式、過濾、自動添加上下文數據、啟用異步日志記錄、錯誤處理行為等等!

鑒于前面提到的限制,一個新的日志包被稱為slog,以填補Go標準庫中的現有空白。這個包旨在通過引入帶有級別的結構化日志記錄來增強Go語言中的日志功能,并為日志創建一個標準接口,其他包可以自由擴展。

結構化日志包slog

slog包源于Jonathan Amsterdam主導的討論,該討論后來促成了描述包的確切設計的建議,一旦它最終確定并在Go版本中實現,預計將放在在log/slog中。在此之前,可以在golang.org/x/exp/slog上找到slog的初步實現。
我們通過介紹它的設計和架構開始討論。這個包提供了三種你應該熟悉的主要類型:

  • Logger:使用slog進行結構化日志記錄的主要API。它提供了諸如(Info()和Error())之類的級別方法來記錄感興趣的事件。
  • Record: Logger創建的一個自成體系的日志記錄對象。
  • Handler:該接口一旦實現,就確定日志記錄的格式和寫入目的地。缺省情況下,這個日志包提供了兩個處理程序:TextHandler和JSONHandler。

在本文的以下部分中,我們將更詳細地概述每種類型(并提供示例)。值得注意的是,雖然提案已經被接受,但在最終發布之前,一些細節可能會發生變化。要跟隨本文中的示例,你可以使用以下命令將slog安裝到項目中:

go get golang.org/x/exp/slog@latest

slog日志包使用

這個slog包公開了一個默認Logger,可以通過包上的頂級函數訪問。該日志記實例默認為INFO級別,并將純文本輸出記錄到標準輸出(類似于標準日志包):

package main

import (
    "errors"

    "golang.org/x/exp/slog"
)

func main() {
    slog.Debug("Debug message")
    slog.Info("Info message")
    slog.Warn("Warning message")
    slog.Error("Error message")
}

Output:

2023/03/15 12:55:56 INFO Info message
2023/03/15 12:55:56 WARN Warning message
2023/03/15 12:55:56 ERROR Error message

還可以通過slog.New()方法創建自己的Logger實例。它接受一個非nil的Handler接口,該接口決定日志的格式和寫入位置。下面是一個使用內置JSONHandler類型將日志格式化為JSON并將其發送到標準輸出的示例:

package main

import (
    "errors"
    "os"

    "golang.org/x/exp/slog"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout))
    logger.Debug("Debug message")
    logger.Info("Info message")
    logger.Warn("Warning message")
    logger.Error("Error message")
}

Output:

{"time":"2023-03-15T12:59:22.227408691+01:00","level":"INFO","msg":"Info message"}
{"time":"2023-03-15T12:59:22.227468972+01:00","level":"WARN","msg":"Warning message"}
{"time":"2023-03-15T12:59:22.227472149+01:00","level":"ERROR","msg":"Error message","!BADKEY":"an error"}

注意,自定義日志默認是INFO級別的,這就是為什么DEBUG條目被抑制的原因。如果你選擇TextHandler代替,每個日志記錄將根據logfmt標準格式化:

logger := slog.New(slog.NewTextHandler(os.Stdout))

Output:

time=2023-03-15T13:00:11.333+01:00 level=INFO msg="Info message"
time=2023-03-15T13:00:11.333+01:00 level=WARN msg="Warning message"
time=2023-03-15T13:00:11.333+01:00 level=ERROR msg="Error message"

自定義默認logger

如果你想配置默認的日志,最簡單的方法是使用slog.SetDefault()方法將默認的記日志實例替換為自定義的:

package main

import (
    "os"

    "golang.org/x/exp/slog"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout))

    slog.SetDefault(logger)

    slog.Info("Info message")
}

你現在應該觀察到日志方法產生的每個記錄都通過JSONHandler路由。
output:

{"time":"2023-03-15T13:07:39.105777557+01:00","level":"INFO","msg":"Info message"}

注意,SetDefault()方法還會更新日志包使用的默認日志實例,以便使用log. printf()和相關方法的現有應用程序可以切換到結構化日志記錄:

logger := slog.New(slog.NewJSONHandler(os.Stdout))

slog.SetDefault(logger)

log.Println("Hello from old logger")

Output:

{"time":"2023-03-16T15:20:33.783681176+01:00","level":"INFO","msg":"Hello from old logger"}

slog.NewLogLogger方法可以將slog.Logger類型實例轉為log.Logger實例(例如http.Server.ErrorLog),如下所示:

handler := slog.NewJSONHandler(os.Stdout)
logger := slog.NewLogLogger(handler, slog.LevelError)

server := http.Server{
  ErrorLog: logger,
}

為日志添加任意屬性

結構化日志的主要優點之一是能夠以鍵/值對的形式向日志添加任意屬性。這些屬性添加了關于正在記錄的日志事件的上下文,這對于故障排除、生成度量或各種其他目的很有用。下面是它如何工作的一個例子:

logger.Info(
  "incoming request",
  "method", "GET",
  "time_taken_ms", 158,
  "path", "/hello/world?q=search",
  "status", 200,
  "user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)",
)

output:

{
  "time":"2023-02-24T11:52:49.554074496+01:00",
  "level":"INFO",
  "msg":"incoming request",
  "method":"GET",
  "time_taken_ms":158,
  "path":"/hello/world?q=search",
  "status":200,
  "user_agent":"Googlebot/2.1 (+http://www.google.com/bot.html)"
}

所有級別方法(Info()、Debug()等)都將日志消息作為第一個參數,并使用無限數量的松散類型鍵/值對。這類似于zap的SugaredLogger API,因為它以額外內存分配為代價優先考慮簡便性。如果你不小心,它也會導致問題。最明顯的是,鍵/值對不完整將產生有問題的輸出。

logger.Info(
  "incoming request",
  "method", "GET",
  "time_taken_ms",
)

由于time_taken_ms鍵沒有對應的值,它將被視為一個帶key的值!
Output:

{
  "time": "2023-03-15T13:15:29.956566795+01:00",
  "level": "INFO",
  "msg": "incoming request",
  "method": "GET",
  "!BADKEY": "time_taken_ms"
}

這并不好,因為屬性不對齊可能會導致創建錯誤的格式,并且直到需要使用日志時才知道錯誤。雖然提案建議對方法中可能出現的缺失鍵/值問題進行詳細檢查,但在審查過程中還需要格外小心,以確保條目中的每個鍵/值對都是平衡的,并且類型是正確的。

為了防止這樣的錯誤,最好使用強類型的上下文屬性,如下所示:

logger.Info(
  "incoming request",
  slog.String("method", "GET"),
  slog.Int("time_taken_ms", 158),
  slog.String("path", "/hello/world?q=search"),
  slog.Int("status", 200),
  slog.String(
    "user_agent",
    "Googlebot/2.1 (+http://www.google.com/bot.html)",
  ),
)

這樣更好,因為在編譯時將檢查每個屬性的正確類型。然而,這并不是萬無一失的,因為沒有什么能阻止你像這樣混合強類型和松散類型的鍵/值對:

logger.Info(
  "incoming request",
  "method", "GET",
  slog.Int("time_taken_ms", 158),
  slog.String("path", "/hello/world?q=search"),
  "status", 200,
  slog.String(
    "user_agent",
    "Googlebot/2.1 (+http://www.google.com/bot.html)",
  ),
)

為了保證向日志添加上下文屬性時的類型安全,必須像這樣使用LogAttrs()方法:

logger.LogAttrs(
  context.Background(),
  slog.LevelInfo,
  "incoming request",
  slog.String("method", "GET"),
  slog.Int("time_taken_ms", 158),
  slog.String("path", "/hello/world?q=search"),
  slog.Int("status", 200),
  slog.String(
    "user_agent",
    "Googlebot/2.1 (+http://www.google.com/bot.html)",
  ),
)

這種方法只接受slog.Attr類型的自定義屬性,因此不可能出現不平衡的鍵/值對。然而,它的API更復雜,因為除了日志消息和自定義屬性外,還需要傳遞一個上下文(或nil)和日志級別給方法。

屬性分組

Slog還提供了將多個屬性分組到一個名稱下的能力。它的顯示方式取決于正在使用的處理程序。例如,使用JSONHandler,組被視為一個單獨的JSON對象:

logger.LogAttrs(
  context.Background(),
  slog.LevelInfo,
  "image uploaded",
  slog.Int("id", 23123),
  slog.Group("properties",
    slog.Int("width", 4000),
    slog.Int("height", 3000),
    slog.String("format", "jpeg"),
  ),
)

Output:

{
  "time":"2023-02-24T12:03:12.175582603+01:00",
  "level":"INFO",
  "msg":"image uploaded",
  "id":23123,
  "properties":{
    "width":4000,
    "height":3000,
    "format":"jpeg"
  }
}

當你的日志被格式化為鍵=值對的序列時,組名將被設置為每個鍵的前綴,如下所示:
output:

time=2023-02-24T12:06:20.249+01:00 level=INFO msg="image uploaded" id=23123
  properties.width=4000 properties.height=3000 properties.format=jpeg

創建和使用子日志實例

在程序給定范圍內生成的所有記錄中包含相同的屬性有時是有需求的,這樣它們就會出現在所有記錄中,而不會在日志點上重復。這就是子日志記錄派上用場的地方,因為它們創建了一個繼承自父日志實例的新日志上下文,但帶有額外的字段。

在slog中創建子日志實例是通過Logger. with()方法完成的,該方法接受強類型和松散類型鍵/值對的混合,并返回一個新的Logger實例。例如,下面的代碼片段,它將程序的進程ID和用于編譯它的Go版本添加到program_info屬性中的每個日志記錄中:

handler := slog.NewJSONHandler(os.Stdout)
buildInfo, _ := debug.ReadBuildInfo()
logger := slog.New(handler).With(
  slog.Group("program_info",
    slog.Int("pid", os.Getpid()),
    slog.String("go_version", buildInfo.GoVersion),

  ),
)

有了這個配置,創建的所日志都將包含program_info屬性下的指定屬性,只要它沒有在日志點被覆蓋:

logger.Info("image upload successful", slog.String("image_id", "39ud88"))
logger.Warn(
  "storage is 90% full",
  slog.String("available_space", "900.1 MB"),
)

Output:

{
  "time": "2023-02-26T19:26:46.046793623+01:00",
  "level": "INFO",
  "msg": "image upload successful",
  "program_info": {
    "pid": 229108,
    "go_version": "go1.20"
  },
  "image_id": "39ud88"
}
{
  "time": "2023-02-26T19:26:46.046847902+01:00",
  "level": "WARN",
  "msg": "storage is 90% full",
  "program_info": {
    "pid": 229108,
    "go_version": "go1.20"
  },
  "available_space": "900.1 MB"
}

你也可以使用WithGroup()方法創建一個子日志記錄器來啟動一個組,這樣所有添加到日志記錄器的屬性(包括那些在日志點添加的屬性)都將嵌套在組名下面:

handler := slog.NewJSONHandler(os.Stdout)
buildInfo, _ := debug.ReadBuildInfo()
logger := slog.New(handler).WithGroup("program_info")

child := logger.With(
  slog.Int("pid", os.Getpid()),
  slog.String("go_version", buildInfo.GoVersion),
)

child.Info("image upload successful", slog.String("image_id", "39ud88"))
child.Warn(
  "storage is 90% full",
  slog.String("available_space", "900.1 MB"),
)

output:

{
  "time": "2023-02-26T19:25:35.977851358+01:00",
  "level": "INFO",
  "msg": "image upload successful",
  "group_name": {
    "pid": 227404,
    "go_version": "go1.20",
    "image_id": "39ud88"
  }
}
{
  "time": "2023-02-26T19:25:35.977899791+01:00",
  "level": "WARN",
  "msg": "storage is 90% full",
  "group_name": {
    "pid": 227404,
    "go_version": "go1.20",
    "available_space": "900.1 MB"
  }
}

自定義日志級別

日志包默認提供了四個日志級別,每個級別都對應一個整數值:DEBUG(-4)、INFO(0)、WARN(4)和ERROR(8)。每個級別之間相差4是經過深思熟慮的設計決策,以適應在默認級別之間使用自定義級別的日志記錄方案。例如,您可以在INFO和WARN之間創建一個自定義的NOTICE級別,其值為1、2或3。

你可能已經注意到,logger默認配置為INFO級別進行打印日志,這將導致以較低嚴重級別(例如DEBUG)記錄的事件被限制。你可以通過HandlerOptions結構來定制這個行為,如下所示:

package main

import (
   "os"

   "golang.org/x/exp/slog"
)

func main() {
   opts := slog.HandlerOptions{
       Level: slog.LevelDebug,
   }

   logger := slog.New(opts.NewJSONHandler(os.Stdout))
   logger.Debug("Debug message")
   logger.Info("Info message")
   logger.Warn("Warning message")
   logger.Error("Error message", errors.New("an error"))
}

output:

{"time":"2023-03-15T13:43:54.949861653+01:00","level":"DEBUG","msg":"Debug message"}
{"time":"2023-03-15T13:43:54.949924059+01:00","level":"INFO","msg":"Info message"}
{"time":"2023-03-15T13:43:54.949927126+01:00","level":"WARN","msg":"Warning message"}
{"time":"2023-03-15T13:43:54.949929822+01:00","level":"ERROR","msg":"Error message"}

注意,這種方法在整個生命周期中修改程序的最小級別。如果你需要動態變化最小日志級別,你必須使用LevelVar類型,如下圖所示:

logLevel := &slog.LevelVar{} // INFO

opts := slog.HandlerOptions{
  Level: logLevel,
}

// 可以通過以下方法任意修改日志級別
logLevel.Set(slog.LevelDebug)

創建自定義日志級別

如果你需要的日志級別超出了slog默認提供的級別,你可以通過實現Leveler接口來創建它們,Leveler接口由一個方法定義:

type Leveler interface {
    Level() Level
}

通過Level類型很容易實現Leveler接口,如下所示(因為Level本身實現了Leveler):

const (
    LevelTrace  = slog.Level(-8)
    LevelNotice = slog.Level(2)
    LevelFatal  = slog.Level(12)
)

一旦像上面那樣定義了自定義級別,你可以像下面這樣使用它們:

opts := slog.HandlerOptions{
    Level: LevelTrace,
}

logger := slog.New(opts.NewJSONHandler(os.Stdout))

ctx := context.Background()
logger.Log(ctx, LevelTrace, "Trace message")
logger.Log(ctx, LevelNotice, "Notice message")
logger.Log(ctx, LevelFatal, "Fatal level")

output:

{"time":"2023-02-24T09:26:41.666493901+01:00","level":"DEBUG-4","msg":"Trace level"}
{"time":"2023-02-24T09:26:41.66659754+01:00","level":"INFO+2","msg":"Notice level"}
{"time":"2023-02-24T09:26:41.666602404+01:00","level":"ERROR+4","msg":"Fatal level"}

注意每個自定義的level屬性是如何根據默認值標記的。這可能不是你想要的,所以你必須使用HandlerOptions類型自定義日志級別名稱:

. . .

var LevelNames = map[slog.Leveler]string{
    LevelTrace:      "TRACE",
    LevelNotice:     "NOTICE",
    LevelFatal:      "FATAL",
}

func main() {
    opts := slog.HandlerOptions{
        Level: LevelTrace,
        ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
            if a.Key == slog.LevelKey {
                level := a.Value.Any().(slog.Level)
                levelLabel, exists := LevelNames[level]
                if !exists {
                    levelLabel = level.String()
                }

                a.Value = slog.StringValue(levelLabel)
            }

            return a
        },
    }

    . . .
}

ReplaceAttr()函數用于自定義程序如何處理日志中的每個鍵/值對。它可用于自定義鍵的名稱,或以某種方式轉換值。在上面的示例中,它用于將自定義日志級別映射到標簽。默認值保持不變,但自定義值分別被賦予了TRACE、NOTICE和FATAL標簽。
output:

{"time":"2023-02-24T09:27:51.747625912+01:00","level":"TRACE","msg":"Trace level"}
{"time":"2023-02-24T09:27:51.747732118+01:00","level":"NOTICE","msg":"Notice level"}
{"time":"2023-02-24T09:27:51.747737319+01:00","level":"FATAL","msg":"Fatal level"}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,345評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,494評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,283評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,953評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,714評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,410評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,940評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,776評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,210評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,642評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,878評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,654評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容