Vert.x Java開發指南——第三章 重構為獨立可重用的Verticle

感興趣的朋友,可以關注微信服務號“猿學堂社區”,或加入“猿學堂社區”微信交流群

版權聲明:本文由作者自行翻譯,未經作者授權,不得隨意轉發。

通過第一次迭代,我們得到了一個可工作的Wiki應用。然而它的實現存在以下問題:

  1. HTTP請求處理和數據庫訪問代碼交織在相同的方法中。
  2. 大量配置數據(如端口號、JDBC驅動等)是代碼中的硬編碼字符串。

3.1 架構和技術選擇

第二次迭代是關于重構代碼為獨立可重用Verticle的:

image

我們將部署兩個Verticle來處理HTTP請求,一個Verticle封裝數據庫持久化。由此產生的Verticle將沒有相互的直接引用,它們將只商定事件總線中的目的地名稱以及消息格式。這種方式提供了一個簡單但有效的解耦。

發送到事件總線的消息將解碼為JSON。雖然Vert.x的事件總線支持靈活的串行化方案用于高要求或者高度定制的上下文,但是使用JSON數據通常是明智的選擇。使用JSON的另一個優勢是它是一種語言無關的格式。由于Vert.x是支持多語言的,對于使用不同語言編寫的Verticle之間的通訊,JSON是非常理想的。

3.2 HTTP Server Verticle

Verticle類開端及start方法看起來如下:

public class HttpServerVerticle extends AbstractVerticle {
    private static final Logger LOGGER = LoggerFactory.getLogger(HttpServerVerticle.class);
    public static final String CONFIG_HTTP_SERVER_PORT = "http.server.port"; ①
    public static final String CONFIG_WIKIDB_QUEUE = "wikidb.queue";
    private String wikiDbQueue = "wikidb.queue";
    @Override
    public void start(Future<Void> startFuture) throws Exception {
        wikiDbQueue = config().getString(CONFIG_WIKIDB_QUEUE, "wikidb.queue"); ②
        HttpServer server = vertx.createHttpServer();
        Router router = Router.router(vertx);
        router.get("/").handler(this::indexHandler);
        router.get("/wiki/:page").handler(this::pageRenderingHandler);
        router.post().handler(BodyHandler.create());
        router.post("/save").handler(this::pageUpdateHandler);
        router.post("/create").handler(this::pageCreateHandler);
        router.post("/delete").handler(this::pageDeletionHandler);
        int portNumber = config().getInteger(CONFIG_HTTP_SERVER_PORT, 8080); ③
        server
            .requestHandler(router::accept)
            .listen(portNumber, ar -> {
                if (ar.succeeded()) {
                    LOGGER.info("HTTP server running on port " + portNumber);
                    startFuture.complete();
                } else {
                    LOGGER.error("Could not start a HTTP server", ar.cause());
                    startFuture.fail(ar.cause());
                }
            });
    }
    // (...)

① 我們暴露了公開的常量用于Verticle配置參數:HTTP端口號以及發送消息到數據庫Verticle的事件總線目的地名稱。

② AbstractVerticle#config()方法允許訪問已提供的Verticle配置。對于沒有指定值的情況,第二個參數是默認值。

③ 配置值不只可以是字符串,也可以是整數、布爾值以及復雜的JSON數據等。

該類剩余部分主要是提取HTTP部分的代碼,以前的數據庫代碼通過事件總線消息替換。這是indexHandler方法的代碼:

private final FreeMarkerTemplateEngine templateEngine = FreeMarkerTemplateEngine.create();
private void indexHandler(RoutingContext context) {
    DeliveryOptions options = new DeliveryOptions().addHeader("action", "all-pages"); ②
    vertx.eventBus().send(wikiDbQueue, new JsonObject(), options, reply -> { ①
        if (reply.succeeded()) {
            JsonObject body = (JsonObject) reply.result().body(); ③
            context.put("title", "Wiki home");
            context.put("pages", body.getJsonArray("pages").getList());
            templateEngine.render(context, "templates", "/index.ftl", ar -> {
                if (ar.succeeded()) {
                    context.response().putHeader("Content-Type", "text/html");
                    context.response().end(ar.result());
                } else {
                    context.fail(ar.cause());
                }
            });
        } else {
            context.fail(reply.cause());
        }
    });
}

① vertx對象提供了對事件總線的訪問,我們發送一個消息到數據庫Verticle的隊列。

② 傳遞選項(DeliveryOptions)允許我們指定頭、有效載荷(payload)編解碼器和超時時間。

③ 一旦成功,回復包含有效載荷。

正如我們所看到的,事件總線消息由一個消息體和選項組成,它可以選擇性地期待一個答復。對于沒有預期答復的情況,有一個send方法的變體,它沒有Handler參數。

我們將有效載荷編碼為JSON對象,并通過一個稱為action的消息頭指定數據庫Verticle應該執行哪個操作。

Verticle的剩余代碼就是路由器處理器,同樣使用事件總線獲取和存儲數據:

private static final String EMPTY_PAGE_MARKDOWN = "# A new page\n" + "\n"
        + "Feel-free to write in Markdown!\n";

private void pageRenderingHandler(RoutingContext context) {
    String requestedPage = context.request().getParam("page");
    JsonObject request = new JsonObject().put("page", requestedPage);
    DeliveryOptions options = new DeliveryOptions().addHeader("action",
            "get-page");
    vertx.eventBus().send(
            wikiDbQueue,
            request,
            options,
            reply -> {
                if (reply.succeeded()) {
                    JsonObject body = (JsonObject) reply.result().body();
                    boolean found = body.getBoolean("found");
                    String rawContent = body.getString("rawContent",
                            EMPTY_PAGE_MARKDOWN);
                    context.put("title", requestedPage);
                    context.put("id", body.getInteger("id", -1));
                    context.put("newPage", found ? "no" : "yes");
                    context.put("rawContent", rawContent);
                    context.put("content", Processor.process(rawContent));
                    context.put("timestamp", new Date().toString());
                    templateEngine.render(
                            context,
                            "templates",
                            "/page.ftl",
                            ar -> {
                                if (ar.succeeded()) {
                                    context.response().putHeader(
                                            "Content-Type", "text/html");
                                    context.response().end(ar.result());
                                } else {
                                    context.fail(ar.cause());
                                }
                            });
                } else {
                    context.fail(reply.cause());
                }
            });
}

private void pageUpdateHandler(RoutingContext context) {
    String title = context.request().getParam("title");
    JsonObject request = new JsonObject()
            .put("id", context.request().getParam("id"))
            .put("title", title)
            .put("markdown", context.request().getParam("markdown"));
    DeliveryOptions options = new DeliveryOptions();
    if ("yes".equals(context.request().getParam("newPage"))) {
        options.addHeader("action", "create-page");
    } else {
        options.addHeader("action", "save-page");
    }

    vertx.eventBus().send(wikiDbQueue, request, options, reply -> {
        if (reply.succeeded()) {
            context.response().setStatusCode(303);
            context.response().putHeader("Location", "/wiki/" + title);
            context.response().end();
        } else {
            context.fail(reply.cause());
        }
    });
}

private void pageCreateHandler(RoutingContext context) {
    String pageName = context.request().getParam("name");
    String location = "/wiki/" + pageName;
    if (pageName == null || pageName.isEmpty()) {
        location = "/";
    }
    context.response().setStatusCode(303);
    context.response().putHeader("Location", location);
    context.response().end();
}

private void pageDeletionHandler(RoutingContext context) {
    String id = context.request().getParam("id");
    JsonObject request = new JsonObject().put("id", id);
    DeliveryOptions options = new DeliveryOptions().addHeader("action",
            "delete-page");
    vertx.eventBus().send(wikiDbQueue, request, options, reply -> {
        if (reply.succeeded()) {
            context.response().setStatusCode(303);
            context.response().putHeader("Location", "/");
            context.response().end();
        } else {
            context.fail(reply.cause());
        }
    });
}

3.3 數據庫Verticle

使用JDBC鏈接到一個數據庫當然需要數據庫驅動以及配置,這些我們在第一次迭代中采用硬編碼的方式實現。

3.3.1 配置SQL查詢

在將前面Verticle的硬編碼值轉換為配置參數的同時,我們還可以進一步從一個配置文件中加載SQL查詢。

查詢語句將從一個文件中加載,這個文件名作為一個配置參數傳遞,如果沒有提供則從一個默認資源加載。這個方法的優點是Verticle可以適配不同的JDBC驅動和SQL方言。

Verticle類的開端包含了主要的配置鍵的定義:

public class WikiDatabaseVerticle extends AbstractVerticle {
    public static final String CONFIG_WIKIDB_JDBC_URL = "wikidb.jdbc.url";
    public static final String CONFIG_WIKIDB_JDBC_DRIVER_CLASS = "wikidb.jdbc.driver_class";
    public static final String CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE = "wikidb.jdbc.max_pool_size";
    public static final String CONFIG_WIKIDB_SQL_QUERIES_RESOURCE_FILE = "wikidb.sqlqueries.resource.file";
    public static final String CONFIG_WIKIDB_QUEUE = "wikidb.queue";
    private static final Logger LOGGER = LoggerFactory.getLogger(WikiDatabaseVerticle.class);
    // (...)

SQL查詢存儲在一個Properties文件中,使用HSQLDB的默認文件位于src/main/resources/db-queries.properties:

create-pages-table=create table if not exists Pages (Id integer identity primary key, Name varchar(255) unique, Content
clob)
get-page=select Id, Content from Pages where Name = ?
create-page=insert into Pages values (NULL, ?, ?)
save-page=update Pages set Content = ? where Id = ?
all-pages=select Name from Pages
delete-page=delete from Pages where Id = ?

WikiDdatabaseVerticle中的以下代碼用于從文件中加載SQL查詢,并將它們放到一個map中:

private enum SqlQuery {
    CREATE_PAGES_TABLE,
    ALL_PAGES,
    GET_PAGE,
    CREATE_PAGE,
    SAVE_PAGE,
    DELETE_PAGE
}

private final HashMap<SqlQuery, String> sqlQueries = new HashMap<>();

private void loadSqlQueries() throws IOException {
    String queriesFile = config().getString(CONFIG_WIKIDB_SQL_QUERIES_RESOURCE_FILE);
    InputStream queriesInputStream;
    if (queriesFile != null) {
        queriesInputStream = new FileInputStream(queriesFile);
    } else {
        queriesInputStream = getClass().getResourceAsStream("/db-queries.properties");
    }
    Properties queriesProps = new Properties();
    queriesProps.load(queriesInputStream);
    queriesInputStream.close();
    sqlQueries.put(SqlQuery.CREATE_PAGES_TABLE, queriesProps.getProperty("create-pages-table"));
    sqlQueries.put(SqlQuery.ALL_PAGES, queriesProps.getProperty("all-pages"));
    sqlQueries.put(SqlQuery.GET_PAGE, queriesProps.getProperty("get-page"));
    sqlQueries.put(SqlQuery.CREATE_PAGE, queriesProps.getProperty("create-page"));
    sqlQueries.put(SqlQuery.SAVE_PAGE, queriesProps.getProperty("save-page"));
    sqlQueries.put(SqlQuery.DELETE_PAGE, queriesProps.getProperty("delete-page"));
}

在接下來的代碼中,我們使用SqlQuery枚舉類型以避免字符串常量。Verticle的start方法代碼如下:

private JDBCClient dbClient;

@Override
public void start(Future<Void> startFuture) throws Exception {
    /*
    * Note: this uses blocking APIs, but data is small...
    */
    loadSqlQueries(); ①
    dbClient = JDBCClient.createShared(vertx, new JsonObject()
        .put("url", config().getString(CONFIG_WIKIDB_JDBC_URL, "jdbc:hsqldb:file:db/wiki"))
        .put("driver_class", config().getString(CONFIG_WIKIDB_JDBC_DRIVER_CLASS, "org.hsqldb.jdbcDriver"))
        .put("max_pool_size", config().getInteger(CONFIG_WIKIDB_JDBC_MAX_POOL_SIZE, 30)));
    dbClient.getConnection(ar -> {
        if (ar.failed()) {
            LOGGER.error("Could not open a database connection", ar.cause());
            startFuture.fail(ar.cause());
        } else {
            SQLConnection connection = ar.result();
            connection.execute(sqlQueries.get(SqlQuery.CREATE_PAGES_TABLE), create -> { ②
                connection.close();
                if (create.failed()) {
                    LOGGER.error("Database preparation error", create.cause());
                    startFuture.fail(create.cause());
                } else {
                    vertx.eventBus().consumer(config().getString(CONFIG_WIKIDB_QUEUE,                       "wikidb.queue"), this::onMessage); ③
                    startFuture.complete();
                }
            });
        }
    });
}

① 有趣的是,我們打破了Vert.x中的一個重要的原則——就是避免阻塞API,但是因為沒有異步API來訪問類路徑上的資源,所以我們的選擇是受限的。我們可以使用Vert.x的executeBlocking方法將阻塞的I/O操作從事件循環轉移到工作者線程,但是由于數據非常小,這么做沒有明顯的效益。

② 這兒是使用SQL查詢的一個示例。

③ consumer方法注冊了一個事件總線目的地Handler。

3.3.2 分發請求

事件總線消息的Handler是onMessage方法:

public enum ErrorCodes {
    NO_ACTION_SPECIFIED,
    BAD_ACTION,
    DB_ERROR
}
public void onMessage(Message<JsonObject> message) {
    if (!message.headers().contains("action")) {
        LOGGER.error("No action header specified for message with headers {} and body {}",
        message.headers(), message.body().encodePrettily());
        message.fail(ErrorCodes.NO_ACTION_SPECIFIED.ordinal(), "No action header specified");
        return;
    }
    String action = message.headers().get("action");
    switch (action) {
        case "all-pages":
            fetchAllPages(message);
            break;
        case "get-page":
            fetchPage(message);
            break;
        case "create-page":
            createPage(message);
            break;
        case "save-page":
            savePage(message);
            break;
        case "delete-page":
            deletePage(message);
            break;
        default:
            message.fail(ErrorCodes.BAD_ACTION.ordinal(), "Bad action: " + action);
    }
}

我們為錯誤定義了一個ErrorCodes枚舉,用來報回消息發送者。為此,Message類的fail方法提供了一個便捷方式答復錯誤,原消息發送者得到一個失敗的AsyncResult.

3.3.3 減少JDBC客戶端樣板文件(譯者注:原文使用的是boilerplate,應該指的是那些重復的代碼)

截止目前,我們可以看到執行一個SQL查詢的完整交互:

  1. 獲取一個鏈接
  2. 執行請求
  3. 釋放鏈接

這導致對每個異步操作需要異常處理的地方都需要編碼,如下:

dbClient.getConnection(car -> {
    if (car.succeeded()) {
        SQLConnection connection = car.result();
        connection.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
            connection.close();
            if (res.succeeded()) {
                List<String> pages = res.result()
                    .getResults()
                    .stream()
                    .map(json -> json.getString(0))
                    .sorted()
                    .collect(Collectors.toList());
                message.reply(new JsonObject().put("pages", new JsonArray(pages)));
            } else {
                reportQueryError(message, res.cause());
            }
        });
    } else {
        reportQueryError(message, car.cause());
    }
});

自從Vert.x 3.5.0開始,JDBC客戶端現在支持一次性(one-shot)操作,獲取一個鏈接執行一個SQL操作,并且在內部釋放。與前面相同的代碼現在簡化如下:

dbClient.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
if (res.succeeded()) {
List<String> pages = res.result()
.getResults()
.stream()
.map(json -> json.getString(0))
.sorted()
.collect(Collectors.toList());
message.reply(new JsonObject().put("pages", new JsonArray(pages)));
} else {
reportQueryError(message, res.cause());
}
});

這對于獲取數據庫鏈接執行一個單獨操作的情況非常有用。但就性能而言,需要注意的是,對于鏈式的SQL操作,重用數據庫鏈接會更好。

該類的剩余部分包含了onMessage分發接收消息時的私有方法調用:

private void fetchAllPages(Message<JsonObject> message) {
    dbClient.query(sqlQueries.get(SqlQuery.ALL_PAGES), res -> {
        if (res.succeeded()) {
            List<String> pages = res.result()
                .getResults()
                .stream()
                .map(json -> json.getString(0))
                .sorted()
                .collect(Collectors.toList());
            message.reply(new JsonObject().put("pages", new JsonArray(pages)));
        } else {
            reportQueryError(message, res.cause());
        }
    });
}

private void fetchPage(Message<JsonObject> message) {
    String requestedPage = message.body().getString("page");
    JsonArray params = new JsonArray().add(requestedPage);
    dbClient.queryWithParams(sqlQueries.get(SqlQuery.GET_PAGE), params, fetch -> {
        if (fetch.succeeded()) {
            JsonObject response = new JsonObject();
            ResultSet resultSet = fetch.result();
            if (resultSet.getNumRows() == 0) {
                response.put("found", false);
            } else {
                response.put("found", true);
                JsonArray row = resultSet.getResults().get(0);
                response.put("id", row.getInteger(0));
                response.put("rawContent", row.getString(1));
            }
            message.reply(response);
        } else {
            reportQueryError(message, fetch.cause());
        }
    });
}

private void createPage(Message<JsonObject> message) {
    JsonObject request = message.body();
    JsonArray data = new JsonArray()
        .add(request.getString("title"))
        .add(request.getString("markdown"));
    dbClient.updateWithParams(sqlQueries.get(SqlQuery.CREATE_PAGE), data, res -> {
        if (res.succeeded()) {
            message.reply("ok");
        } else {
            reportQueryError(message, res.cause());
        }
    });
}

private void savePage(Message<JsonObject> message) {
    JsonObject request = message.body();
    JsonArray data = new JsonArray()
        .add(request.getString("markdown"))
        .add(request.getString("id"));
    dbClient.updateWithParams(sqlQueries.get(SqlQuery.SAVE_PAGE), data, res -> {
        if (res.succeeded()) {
            message.reply("ok");
        } else {
            reportQueryError(message, res.cause());
        }
    });
}

private void deletePage(Message<JsonObject> message) {
    JsonArray data = new JsonArray().add(message.body().getString("id"));
    dbClient.updateWithParams(sqlQueries.get(SqlQuery.DELETE_PAGE), data, res -> {
        if (res.succeeded()) {
            message.reply("ok");
        } else {
            reportQueryError(message, res.cause());
        }
    });
}

private void reportQueryError(Message<JsonObject> message, Throwable cause) {
    LOGGER.error("Database query error", cause);
    message.fail(ErrorCodes.DB_ERROR.ordinal(), cause.getMessage());
}

3.4 從主Verticle部署Verticle

我們依舊有一個MainVerticle類,但它不是像首次迭代一樣包含所有邏輯,它的唯一目的是啟動應用并且部署其它Verticle。

這些代碼包括部署一個WikiDatabaseVerticle實例和兩個HttpServerVerticle實例:

public class MainVerticle extends AbstractVerticle {

@Override
public void start(Future<Void> startFuture) throws Exception {
    Future<String> dbVerticleDeployment = Future.future(); ①
    vertx.deployVerticle(new WikiDatabaseVerticle(), dbVerticleDeployment.completer()); ②
    dbVerticleDeployment.compose(id -> { ③
        Future<String> httpVerticleDeployment = Future.future();
        vertx.deployVerticle("io.vertx.guides.wiki.HttpServerVerticle", ④
                new DeploymentOptions().setInstances(2), ⑤
                httpVerticleDeployment.completer());
        return httpVerticleDeployment; ⑥
        }).setHandler(ar -> { ⑦
            if (ar.succeeded()) {
                startFuture.complete();
            } else {
                startFuture.fail(ar.cause());
            }
        });
    }
}

① 部署Verticle是一個異步操作,因此我們需要一個Future。String參數類型是因為一個Verticle部署成功時會返回一個標識。

② 一種選擇是使用new創建一個Verticle實例,傳遞對象引用給deploy方法。completer返回值是一個處理器,簡單的完成future。

③ 使用compose的順序組合允許在一個操作之后運行另一個異步操作。當初始化future成功完成之后,調用組合方法。

④ 指定一個類名字符串也是部署Verticle的一種選項。對于其它JVM語言來說,基于字符串的慣例(conventions)允許指定一個模塊/腳本。

⑤ DeploymentOption類允許指定一些參數,尤其部署的實例個數。

⑥ 組合方法返回下一個future。它的完成將會觸發組合操作的完成。

⑦ 我們定義了一個Handler,以便完成MainVerticle的啟動future。

精明的讀者可能會感到驚奇,我們怎么可以在同一個端口部署HTTP Server代碼兩次,并且對于每個實例都不期望出現由于TCP端口已經被占用而導致的任何錯誤。對于許多Web框架,我們需要選擇不同的TCP端口,并且有一個前端HTTP代理來執行端口之間的負載平衡。

對于Vert.x則不需要這么做,多個Verticle可以共享相同的TCP端口號。傳入的連接只是簡單的通過接收線程以輪轉的方式分發。

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

推薦閱讀更多精彩內容