Protocol Buffer 基礎教程: Java

原文地址:
https://developers.google.com/protocol-buffers/docs/javatutorial

這個教程為Java開發者使用protocol buffers工作提供一個基本的介紹。通過創建一個簡單的示例程序,向你展示如何:

  • 在一個.proto文件中定義message格式
  • 使用protocol buffer編譯器
  • 使用Java protocol buffer的API來讀寫message

這不是一個深入介紹如何通過Java使用protocol buffers的教程。你可以通過Protocol Buffer Language Guide, the Java API Reference, the Java Generated Code Guide, and the Encoding Reference.來獲取更多的參考信息。

為什么要使用Protocol Buffers?

我們將要使用的例子是一個非常簡單的“地址簿”應用,這個應用可以從一個文件里讀寫人們的聯系方式。每個在地址簿中的人都有一個名字, 一個ID, 一個email地址,和一個聯系電話號碼。

你是如何序列化并尋回像這樣的結構化數據的呢?有這樣一些方法可以解決這個問題:

  • 使用Java序列化。這是默認的方式,因為它是內建于語言的,但是它有諸多的眾所周知的問題(參見Effective Java,by Josh Bloch pp. 213),并且如果你需要與C++或者Python寫成的應用共享數據的時候,這種方式并不能很好的工作。
  • 你可以發明一種ad-hoc的方式來將數據項編碼成一個單獨的串--比如編碼 4 ints為“12:3-23:67”。這是一種簡單并且靈活的方式,雖然它需要寫編碼和轉碼的代碼,并且轉碼的過程會消耗一些運行時間。這種方式對于編碼一些簡單的數據是最好的。
  • 將數據序列化為XML。這種方式是非常吸引人的,因為XML幾乎是人類可讀的,并且許多語言都有支持XML的庫可以使用。如果你想同其它應用/項目共享數據,這將是一個很好的選擇。然而,XML的臭名昭著的空格密集型,使得編碼和解碼的過程給應用帶來巨大的性能問題。與此同時,導航一個XML DOM樹比起導航一個類中簡單的域屬性來說會帶來更大的復雜性。

Protocol buffers是靈活,高效,自動化的解決方案,來解決這個問題。通過protocol buffers,你寫一個.proto的描述來描述你想要存儲的數據結構。proto buffer編譯器會通過這個.proto創建一個類,這個類實現了自動化的編碼和將proto buffer數據的轉化為高效的二進制格式。生成的類為域屬性提供了getters和setters,并實現將proto buffer作為一個單元來進行讀寫的功能。更重要的是,proto buffer格式支持擴展格式,因此,使用這種方式的代碼依舊能夠讀取通過舊格式編碼的數據。

哪里可以找到示例代碼?

示例代碼是被包含在源碼包中的,在名為“example”目錄下面。點擊下載

定義你的Protocol格式

為了創建你的地址簿應用,你將需要從一個.proto文件開始。在.proto文件中定義很簡單:你為每一個想要序列化的數據結構添加一個message,然后在message中為每一個屬性域指定一個name和一個type。下面是定義了你的message的.proto文件,addressbook.proto

package tutorial;

option java_package = "com.example.tutorial";
option java_outer_classname = "AddressBookProtos";

message Person {
  required string name = 1;
  required int32 id = 2;
  optional string email = 3;

  enum PhoneType {
    MOBILE = 0;
    HOME = 1;
    WORK = 2;
  }

  message PhoneNumber {
    required string number = 1;
    optional PhoneType type = 2 [default = HOME];
  }

  repeated PhoneNumber phone = 4;
}

message AddressBook {
  repeated Person person = 1;
}

你可以看到語法與C++或者Java很相似。下面我們來仔細看看這個文件的每一部分以及它們的作用。

.proto文件起始于一個包聲明,這能夠幫助在不同的工程中避免命名沖突。在使用Java的情況下,package name被用來作為Java的package,除非你詳細指定了一個java_package,正如我們在這里是這樣做的。即使你卻是提供了一個java_package,你也應該仍舊定義一個正常的package以便在Protocol Buffers命名空間和非Java語言中避免命名沖突。

在包聲明以后,你會看到兩個Java特有的配置項:java_packagejava_outer_classnamejava_package指定你生成的類要存在于什么包下。如果你沒有具體的指定,它會簡單的與package給出的package name相匹配,但是這些名字通常不適合做為Java包的名字(因為它們通常不以域名開頭)。java_outer_classname配置項定義了那些在這個文件中包含所有類的名稱。如果你沒有具體指定java_outer_classname,它將會轉換文件名為駝峰式來產生。例如,“my_proto.proto”將默認使用“MyProto”做為outer class name(類的文件名)。

接下來是你的message定義。一個message只是一系列具有類型的域的集合。許多標準的簡單類型可以做為可用的域類型,包括bool, int32, float, double, 和string。你同樣可以向你的message中添加更多的結構,通過把其他的message類型當做域類型使用--在上面的例子中,Person message包含PhoneNumber message,而AddressBook message包含Person message。你甚至可以通過內部嵌套其它message的方式定義message,你可以看到,PhoneNumber類型被定義在Person里面。你同樣可以定義enum類型,如果你希望其中的一個你的域擁有預定義列表中的一個值--在這里,你希望指定一個phone number的值是MOBILE, HOME, 或者WORK中的一個。

這里在每個元素上面的“=1”,“=2”標記,辨識唯一的“tag”,這些“tag”是域在二進制編碼的時候使用的。Tag編號從1到15,需要比更大的數字少一個字節,因此,出于優化考慮你可以決定使用常用的或者重復的元素。而從16到更高的編號留給不常用的可選元素。在重復域中的每一個元素都需要重新編碼tag number,所以,使用重復域對優化來說是特別好的選擇。

每一個域都必須被下面之一的modifer注解:

  • required:域的值必須被提供,否則message會被認為是“未初始化的”。試圖創建一個未初始化的message將會拋出一個RuntimeException。轉化一個未初始化的message將會拋出一個IOException。除了這些,required域的行為和optional域表現相同。
  • optional:該域可能被設置,也可能未被設置。如果一個optional域未被設置,默認的值將會被使用。對于簡單類型來說,你可以指定你自己的默認值,就像我們在例子中對phone number類型所做的那樣。否則,一個系統默認的值將被使用:數值類型是0,字符串類型是"",布爾類型是false。對于嵌入式的message來說,默認值總是這個message的沒有任何域被設置過的“默認實例”或“原型”。調用訪問器去獲取一個optional或者required域的值,這些域如果沒有被具體設置,那么總是返回域的默認值。
  • repeated:域將會被重復任意次數(包括0次)。重復的值的順序將會被保存在protocol buffer中。可以把重復的域想成變長的數組。

Required是永久的。你在將域標記成required的時候需要很小心。如果在某些時候你希望停止寫入或發送一個required域,它將會易出問題地將域轉為一個optional域--以前的讀取器將認為沒有這個域的message是不完整的,并且可能無意中將它們拒絕或者丟棄。取而代之地,你應該考慮為你的buffer寫應用特有的自定義校驗規則。在Google的一些工程師得出了結論就是:與帶來的益處相比,使用required帶來的壞處會更多。他們傾向于只使用optionalrepeated。然而,這種情景并不是普遍的。

你將找到一個完整的教程關于寫.proto文件--包括所有可能的域類型--在Protocol Buffer Language Guide。不要試圖尋找類似于類繼承機制的組件,因為protocol buffer不做這些。

編譯你的Protocol Buffers

現在你有一個.proto,接下來你需要生成類,這些類是你需要讀寫AddressBook(當然也包括PersonPhoneNumber) message用的。為了做到這點,你需要運行proto buffer的編譯器protoc在你的.proto上:

  1. 如果你還沒有安裝編譯器,下載這個包并按照README中介紹的步驟操作。
  2. 現在,運行編譯器,指定源目錄(你應用的源代碼所在的地方--如果你不提供這個值,將使用當前目錄),目標目錄(你希望生成的代碼所在的地方,通常與$SRC_DIR相同),并你的.proto的路徑。在這個例子中,你運行:
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/addressbook.proto

因為你想要Java類,所以使用--java_out選項--其它被支持的語言的該選項相似。

這將生成com/example/tutorial/AddressBookProtos.java在你指定的目標目錄中。

Protocol Buffer API

讓我們來看一些生成的代碼,并看看編譯器都為你生成了什么類和方法。如果你看看AddressBookProtos.java文件,你能看到它定義了一個叫做AddressBookProtos的類,嵌套在其中的是你在addressbook.proto中指定的每一個類。每一個類都有自己的Builder類,通過這個類你可以創建那個類的實例。你可以在下面的Builders vs. Messages找到關于builder的更多信息。

message和builder都有自動生成的為message中每個域準備的訪問器方法。message只有getter方法,而builder同時有getter和setter方法。下面是Person類的一些訪問器:

// required string name = 1;
public boolean hasName();
public String getName();

// required int32 id = 2;
public boolean hasId();
public int getId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();

// repeated .tutorial.Person.PhoneNumber phone = 4;
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
public PhoneNumber getPhone(int index);

同時,Person.Builder有同樣的getter和setter:

// required string name = 1;
public boolean hasName();
public java.lang.String getName();
public Builder setName(String value);
public Builder clearName();

// required int32 id = 2;
public boolean hasId();
public int getId();
public Builder setId(int value);
public Builder clearId();

// optional string email = 3;
public boolean hasEmail();
public String getEmail();
public Builder setEmail(String value);
public Builder clearEmail();

// repeated .tutorial.Person.PhoneNumber phone = 4;
public List<PhoneNumber> getPhoneList();
public int getPhoneCount();
public PhoneNumber getPhone(int index);
public Builder setPhone(int index, PhoneNumber value);
public Builder addPhone(PhoneNumber value);
public Builder addAllPhone(Iterable<PhoneNumber> value);
public Builder clearPhone();

你可以看到,每一個域都有簡單的JavaBeans風格的getters和setters方法。如果一個域被設置了值,同樣會有getters為每一個單獨的域。最后,每一個域都有一個clear方法,用來將域設置回原來的空狀態。

Repeated域有一些額外的方法--一個Count方法(用來速記列表的大小),getters和setters用來通過下標,get或者set一個具體的元素。一個add方法用來向列表中追加一個新元素。一個addAll方法用來追加整個容器中的元素到列表中。

請注意這些訪問器方法是如何使用駝峰式的命名,即使.proto文件使用了小寫字母+下劃線的方式。這種格式的轉換是由protocol buffer的編譯器自動完成的,以便于生成的類可以符合標準的Java風格規范。你應該總是為.proto文件中的域名稱使用“小寫字母+下劃線”的方式;這確保了好的命名實踐在所有的生成的語言里。參考style guide以了解更多好的.proto風格。
了解更多關于編譯器對于任何具體的域定義會生成什么樣的成員,請參見Java generated code reference

枚舉和內嵌類

生成的代碼中包含一個PhoneTypeJava 5 enum, 內嵌于Person

public static enum PhoneType {
  MOBILE(0, 0),
  HOME(1, 1),
  WORK(2, 2),
  ;
  ...
}

內嵌的類型Person.PhoneNumber被生成,正如你所期望的那樣,作為一個內嵌類在Person中。

Builders vs. Messages

protocol buffer生成的所有message類都是不可變的(immutable)。一旦一個message對象被構建,它就不能被修改,就像Java的String類型一樣。為了構建一個message,你必須首先構建一個builder,給任何域設置你想要設置的值,然后調用builder的build()方法。

你或許已經注意到builder的每一個修改message的方法都會返回一個新的builder。返回的對象和你調用方法時使用的其實是同一個builder。它被返回是為了方便,使你能夠將若干setters組成一串,在代碼中書寫為一行(譯者注:鏈式編程)。

這里是一個你如何創建一個Person實例的例子:

Person john =
  Person.newBuilder()
    .setId(1234)
    .setName("John Doe")
    .setEmail("jdoe@example.com")
    .addPhone(
      Person.PhoneNumber.newBuilder()
        .setNumber("555-4321")
        .setType(Person.PhoneType.HOME))
    .build();

標準的Message方法

每一個message和builder類也包含一些其它的方法,這些方法使你可以檢查或操作整個message,其中包括:

  • isInitialized():檢查是否所有的required域都已經被設置過。
  • toString():返回一個便于人眼閱讀的message的展示,在debug的時候特別有用。
  • mergeFrom(Message other):(builder特有)合并other的內容到這個message中,如果是單數域則覆蓋,如果是重復域則追加連接。
  • clear():(builder特有)清空所有的域,回到空值狀態。

這些方法實現了MessageMessage.Builder接口,這些接口被所有的Java message和builder共享。更多的信息,請參見complete API documentation for Message

轉化和序列化

最后,每一個protocol buffer類都有一些方法用來讀寫你使用protocol buffer二進制格式選擇的message。這包括:

  • byte[] toByteArray();:序列化message并返回一個包含它原始字節的byte數組。
  • static Person parseFrom(byte[] data);:從給出的byte數組轉化一個message。
  • void writeTo(OutputStream output);:序列化message,并將其寫入一個OutputStream
  • static Person parseFrom(InputStream input);:從一個InputStream中讀取并轉化一個message。

這些只是一些提供的選項來轉化和序列化。再次參見Message API reference的完整列表。

Protocol Buffers和面向對象Protocol buffer類是基本的不發揮作用的數據持有者(像是C++中的結構體);它們不創建第一個類成員在一個對象模型中。如果你想要向一個生成的類中添加豐富的行為,最好的方式是用一個應用特有的類包含生成的protocol buffer類。這樣做同樣是一個好的方式,如果你對.proto文件的設計沒有控制權的時候(這是說,如果你在從另一個項目中重用一個.proto文件)。在這種情況下,你可以用包含的類去精巧地設計一個接口使它更適合你應用特有的環境:隱藏一些數據和方法,暴露便于使用的功能,等等。你絕不應該去通過繼承它們來向生成的類中添加行為。這將會破壞內部機制,并且畢竟不是面向對象的做法。

寫一個Message

現在,讓我們試著使用你的protocol buffer類。第一件你想讓你的地址簿應用能夠做的事情是向你的地址簿文件寫入個人詳情。為了做這個,你需要創建并安置你protocol buffer類的實例,并且接下來將它們寫入到一個輸出流中。

下面是一個能從一個文件中讀取一個AddressBook的程序,基于用戶的輸入向其中添加一個新的Person,并再次將AddressBook回寫到文件中。

import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintStream;

class AddPerson {
  // This function fills in a Person message based on user input.
  static Person PromptForAddress(BufferedReader stdin,
                                 PrintStream stdout) throws IOException {
    Person.Builder person = Person.newBuilder();

    stdout.print("Enter person ID: ");
    person.setId(Integer.valueOf(stdin.readLine()));

    stdout.print("Enter name: ");
    person.setName(stdin.readLine());

    stdout.print("Enter email address (blank for none): ");
    String email = stdin.readLine();
    if (email.length() > 0) {
      person.setEmail(email);
    }

    while (true) {
      stdout.print("Enter a phone number (or leave blank to finish): ");
      String number = stdin.readLine();
      if (number.length() == 0) {
        break;
      }

      Person.PhoneNumber.Builder phoneNumber =
        Person.PhoneNumber.newBuilder().setNumber(number);

      stdout.print("Is this a mobile, home, or work phone? ");
      String type = stdin.readLine();
      if (type.equals("mobile")) {
        phoneNumber.setType(Person.PhoneType.MOBILE);
      } else if (type.equals("home")) {
        phoneNumber.setType(Person.PhoneType.HOME);
      } else if (type.equals("work")) {
        phoneNumber.setType(Person.PhoneType.WORK);
      } else {
        stdout.println("Unknown phone type.  Using default.");
      }

      person.addPhone(phoneNumber);
    }

    return person.build();
  }

  // Main function:  Reads the entire address book from a file,
  //   adds one person based on user input, then writes it back out to the same
  //   file.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  AddPerson ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    AddressBook.Builder addressBook = AddressBook.newBuilder();

    // Read the existing address book.
    try {
      addressBook.mergeFrom(new FileInputStream(args[0]));
    } catch (FileNotFoundException e) {
      System.out.println(args[0] + ": File not found.  Creating a new file.");
    }

    // Add an address.
    addressBook.addPerson(
      PromptForAddress(new BufferedReader(new InputStreamReader(System.in)),
                       System.out));

    // Write the new address book back to disk.
    FileOutputStream output = new FileOutputStream(args[0]);
    addressBook.build().writeTo(output);
    output.close();
  }
}

讀取一個Message

當然,如果你無法從一個地址簿中獲得任何信息,那么這個地址簿是沒有多大用處的。這個例子讀取上個例子中創建的那個文件,并將其中的所有信息打印出來。

import com.example.tutorial.AddressBookProtos.AddressBook;
import com.example.tutorial.AddressBookProtos.Person;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintStream;

class ListPeople {
  // Iterates though all people in the AddressBook and prints info about them.
  static void Print(AddressBook addressBook) {
    for (Person person: addressBook.getPersonList()) {
      System.out.println("Person ID: " + person.getId());
      System.out.println("  Name: " + person.getName());
      if (person.hasEmail()) {
        System.out.println("  E-mail address: " + person.getEmail());
      }

      for (Person.PhoneNumber phoneNumber : person.getPhoneList()) {
        switch (phoneNumber.getType()) {
          case MOBILE:
            System.out.print("  Mobile phone #: ");
            break;
          case HOME:
            System.out.print("  Home phone #: ");
            break;
          case WORK:
            System.out.print("  Work phone #: ");
            break;
        }
        System.out.println(phoneNumber.getNumber());
      }
    }
  }

  // Main function:  Reads the entire address book from a file and prints all
  //   the information inside.
  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.err.println("Usage:  ListPeople ADDRESS_BOOK_FILE");
      System.exit(-1);
    }

    // Read the existing address book.
    AddressBook addressBook =
      AddressBook.parseFrom(new FileInputStream(args[0]));

    Print(addressBook);
  }
}

擴展一個Protocol Buffer

當你發布了使用protocol buffer的代碼后,毫無疑問你遲早會想改進protocol buffer的定義。如果你想要你新的buffer能夠向后兼容,并且你的舊buffer向前兼容(你一定會想這么做),那么你就需要遵守下面的一些約定。在新版本的protocol buffer中:

  • 你一定不能改變任何已存在的域的tag number。
  • 你一定不能添加或刪除任何required域。
  • 你可以刪除optional域或repeated域。
  • 你可以添加新的optional域或repeated域,但是你必須使用新的tag number(比如:你在這個protocol buffer中從沒使用過的tag number,甚至不能是已刪除的域的tag number)。

(關于這些約定有一些例外的情況,但它們極少被用到。)

如果你遵守這些規則,舊的代碼將會很好地讀取新的message,并且輕易地忽略任何新的域。對于舊的代碼來說,被刪除的optional域將有它們的默認值,并且被刪除的repeated域將會為空。新的代碼將會透明地讀取舊的message。然而,需要牢記的是新的optional域將不會在舊的message中出現,所以你將需要具體檢查它們是否被has_設置,或者提供一個可靠的默認值在你的.proto文件中,即在tag number后面寫[default = value]。如果一個optional元素未被具體指定,那么取而代之地,一個具體類型的默認值將被使用:對于string,默認值是空串。對于boolean,默認值是false。對于numeric類型,默認值是0。同樣記得,如果你添加了一個新的repeated域,你的新代碼將不能識別它是被置空(通過新的代碼)還是從來沒有被設置過(通過舊的代碼)。因為,沒有為它提供has_標記。

高級用法

Protocol buffers有一些超過訪問器和序列化的用法。請確保查看過Java API reference來看看你還能用它做些什么。

Protocol message類提供的一個關鍵特性是反射。你可以迭代一個message中的域,并且不用寫任何與message中類型抵觸的代碼,來操作它們的值它們的值。一個非常有用的方法是使用反射來將protocol message轉為或轉自其它的編碼方式,比如:XML或JSON。一個更高級的反射用法是查找相同類型的兩個message的不同之處,或者開發一套“protocol message的正則表達式”,使得你可以通過寫表達式來匹配確定的message內容。如果發揮你的想象力,通過應用Protocol Buffer去解決一些的問題會遠超出你的預期!

反射被作為MessageMessage.Builder接口的一部分提供出來。

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

推薦閱讀更多精彩內容