Eden Monorepo 学习笔记

前言

Monorepo 是代码是代码的单仓库维护方式,已经在 Google、Facebook、微软等公司使用了很多年,该模式的主要特点是将所有代码都集中到一个仓库中管理。与 Monorepo 相对的是 Multirepo,该模式下的每个模块都有独立的仓库。由于目前业务用到此方式,故对此方式进行学习梳理,会分别从相关概念、基础功能、特性、优点、目录结构、使用等进行介绍。

Monorepo 简介

什么是 Monorepo?

Monorepo 是一种代码管理模式,是将多个项目组织到一个 repo 中,具有以下特点:
1、子项目是独立的,可以独立开发、测试、部署
2、子项目类型可以是任意的,可以是 web 项目、server 项目等
3、子项目间可能有依赖关系,例如一些顶层的项目会依赖于底层项目暴露的 API
对于前端而言,Monorepo 项目分为两类:
1、业务型子项目:子项目是需要部署上线的
2、lib 型子项目:前端的 package ,需要 publish 到 npm registry

为什么使用 Monorepo?

随着业务的不断增长,一个团队往往会同时维护和迭代多个项目,这些项目之间往往需要共享代码(组件库、工具函数等等)、共享基础设施(构建工具、lint等)。而在 MultiRepo 模式中,跨项目的代码共享其实是非常麻烦的,常用的方式是将公共代码提取出来成一个单独的仓库,然后将公用仓库的产物发布到 npm registry——一般复用组件库就是这种形式:


image.png

在上图中,每一个顶层的 App 都在一个单独的仓库中,都有自己的一套基础设施、CI/CD 流程等,这些App 的共用部分被拆分到独立的仓库中,作为 npm 依赖引入。这看起来是个不错的解决方案,但是也会有一些问题:
1、重复的基础设施: 每个项目都需要单独设置 CI 流程、配置开发环境,配置部署发布流程。这样的重复可能带来不一致性,使用不一致的构建设施、规范等。将会大大提高多项目的基建维护成本,每个项目的基建都需要专人维护,而且随着时间的推移,项目越来越大,相应的基建维护成本也会越来越高。同时开发多个项目时,也会有比较大的项目切换成本。
2、割裂的工作流:多个项目组成的工程体系是不连续的,每一个仓库都有自己的一套工作流程。当进行多项目协作开发的时候,整体的工作流是割裂的:假如需要修改公共库的一个函数,首先需要修改/调试公共库的仓库(跨项目的调试就很困难),然后跑工具库的 CI 流程,然后升级需要使用该函数 App 的依赖,然后调试、App,这一套流程非常的繁琐并且不连续。
3、复杂的版本管理:仓库之间的依赖关系随着时间管理会变得复杂,最开始版本都是1.0.0,但是随着版本迭代,项目1升级到了v2,其他依赖的项目可能升级了,也可能没有升级。时间越长,版本依赖关系就越混乱。混乱的版本会降低项目的可维护性
而采用 Monorepo 可以较好的解决这些问题:
1、统一的 CI 流程和基建: 一套工具、规范落地所有项目,无需重复切换开发环境,极大减轻多项目协作开发的成本。只需要为 Monorepo 配置好基础设施,后续所有新建的项目都可以复用已有的基建,只需要 1-2 个同学维护所有项目的基建,基建成本大大降低
2、一致的工作流:当工具库升级以后,顶层应用可以同时感知到其依赖的升级,能够非常方便的调试工具库的修改,并开发完成后自动触发相关项目的测试发布流程,极大简化多项目的管理并且实现自动化。
3、代码共享和团队协作能力:在Monorepo中能非常方便的复用代码,同时能够方便的检索到各项目的源码,能够降低团队成员间的沟通协作成本。


image.png

Eden Monorepo 的工程化建设简介

Monorepo 并不是简简单单将多个项目杂糅在一个仓库里面,而是将多个项目作为一个工程整体,收敛到一个仓库中管理。Monorepo 需要一套完整的工程化体系来支撑多项目的初始化、开发、构建、CI/CD 等,并且 Monorepo 对多项目研发效率、性能和质量有更高的要求。

什么是 Eden Monorepo

Eden Monorepo 是一套高性能、可扩展、开箱即用的 Monorepo 解决方案,提供了现代化的工具链以及场景解决方案来解决大规模 Monorepo 工程化痛点,一键完成 Monorepo 及子项目的初始化、开发、构建、CI/CD 等全研发流程,支持 Web(React、Vue)、Lynx、Gulu、Library、微前端 等海量研发场景。

Eden 实现的 Monorepo 方案,相比其他方案,有如下特点

1、完整工具链、开箱即用: 紧密贴合公司内部基础设施,提供 CLI 工具支撑初始化、开发、测试、CI、发布、上线等环节,无需手动去写各种流程适配版本。
2、执行效率高:对依赖安装、构建产物、测试结果等实现了多级缓存,采用最大限度的批量任务并行加速。 按需构建 + 多级缓存高效解决 Monorepo 下的构建效率问题
3、海量的脚手架模版:支持 react、gulu、vue、lynx、组件库、工具库 等子项目,可以在几分钟内快速搭建一个庞大的 monorepo 项目。
4、优化的依赖管理:极速依赖安装,一致的 node_modules 结构,partial install,无 phantom dependency 问题。
5、规范化:提供全面的规范检查工具(Checker,Eslint Plugin)解决 monorepo 中容易存在的依赖重复,依赖缺失等问题。
6、插件化:所有基础能力均可以通过自定义插件的方式扩展,包括初始化流程、CI/CD Pipeline、Builder、Checker 等,可以基于 Eden Monorepo 已有的能力自定义扩展。Eden Monorepo 也提供了 node api,可以使用 eden monorepo 的 api 搭建自定义的 Monorepo 工具。
7、高性能的CI/CD Pipeline:按需安装依赖+缓存、按需构建+构建缓存、按需执行 pipeline,同时支持分布式 CI,将子项目 CI 流程分组放到不同机器上加速。
8、自动化发包:提供了完善的版本管理、changelog 生成,多包发布等能力,支持基于 CI 自动化升级版本和 changelog,自动发布。
9、开发体验:对于 web/gulu 项目支持直接全源码开发、调试,更高效的 HMR

适用场景

所有的工具都有适用于它的使用场景,Eden Monorepo的使用场景如下:
当团队有多项目需求的时候,希望:
统一多项目的基础设施,减少基建成本
统一多项目的研发规范,降低维护成本
多个项目间便捷的共享和调试公用代码
多项目采用一致且连续的工作流(CI、Code Review、Gitflow 等等)
解决 Monorepo 的 CI/CD 性能问题
解决 Monorepo 的依赖管理问题
可以采用此方式解决

基础功能

Workspaces 管理

一个 workspace 是被 Eden Monorepo 管理的目录,是 Eden Monorepo 中进行依赖安装、开发、构建、测试、部署 等的最小操作单元。
在 Eden Monorepo 中,一个 workspace 必须在 eden.monorepo.json 中的 workspaces(或 packages)中注册,才能被 Eden Monorepo 管理(使用依赖安装、开发构建等功能)。Eden 管理的 workspace 不一定是一个 package,可能是任意的目录。

注册 workspace

在 eden.monorepo.json 中的 workspaces(或 packages)字段中注册,如下:

{
  "packages": [
    {
      "name": "@eden/react-web",
      "path": "packages/apps/react-web",
      "shouldPublish": false,
      "category": "apps",
      "tags": [
        "admin"
      ],
      "sourceEntryFile": "src",
      "builders": {
        "operations": {
          "start": {
            // ...
          },
          "build": {
            // ...
          }
        }
      }
    },
  ]
}

上例子中注册了一个名为 @eden/react-web 的 workspace,其中 name 一般和 package.json 的 name 相同,name 和 path 这两个字段是必须的。
举例(私域项目):

image.png

详细的配置文件可参考:https://eden.bytedance.net/solution/monorepo/configuration/eden-mono-json

目录结构

.
├── .codebase
│   └── pipelines                           // CI pipelines
│       ├── eden_pipeline_before_merge_master.yaml  // 向 master 提交/更新 MR 时触发
│       └── eden_pipeline_after_merge_master.yaml   // 合入 master 后后触发
├── configs                                 // 共享配置文件
│   └── tsconfig.base.json
├── eden.mono.pipeline.json                 // mono pipeline 配置
├── eden.monorepo.json                      // mono 配置
├── infra                                   // Monorepo 整体的基建目录
│   ├── .npmrc                              // 可以配置 Monorepo 整体依赖安装的行为
│   ├── .commitlintrc.js                    // commitlint 校验配置
│   ├── generators                          // 项目/代码生成器目录
│   ├── git-hooks                           // husky 的 git hooks 配置
│   │   ├── commit-msg                      // 确认 commit-msg 后触发,用于校验 commit-msg 是否符合 commitlint 规范
│   │   └── pre-commit                      // 在 commit 前触发,校验更改的文件是是否符合 lint 规范
│   ├── package.json                        // 用于项目基建的依赖如 husky、commitlint 等安装到这里
│   └── pnpm-lock.yaml                      // 所有项目共享的 lock 文件
├── apps                                    // apps 类型的子项目
├── libs                                    // libs 类型的子项目
├── packages                                // 需要发包的 libs 类型的子项目
├── .edenlintrc.json                        // eden-lint 配置文件
├── .edenlintignore
├── .gitignore
├── .lintstagedrc.json                      // lint-staged 配置文件,触发 eden-lint
├── README.md
└── build.sh                            // 所有项目的 scm 构建入口

.codebase 目录
.codebase 目录下主要存放 Codebase CI 相关的配置,可以配置在代码合入 master 前,合入 master 后自动触发的行为,例如代码合入前的测试以及代码合入后自动发布等等
configs 目录
configs 目录下主要存放一些子项目间通用的配置文件,例如 tsconfig、eden config、jest config 等等。
infra 目录
infra 目录下主要存放 Monorepo 整体的一些基建能力,比如:

  • infra/package.json 中会安装通用的依赖,比如 @ies/eden-linthuskycommitlintlint-staged@ies/eden-monorepo@ies/eden-init 等等。自定义脚本需要安装的依赖也可以在 infra 下安装
  • infra/.npmrc 中进行依赖安装相关的配置,例如配置使用的 registry、配置 pnpm 的行为等,可以参考文档 .npmrc
  • infra/.commitlintrc.js 配置 commitlint 的校验规则
  • infra/git-hooks 下配置 git hooks,通过 husky 自动注册,一般一般会在 pre-commit 阶段执行 eden-lint,在 commit-msg 阶段执行 commitlint
  • pnpm-lock.yaml 是所有项目共享的 lock 文件,该文件需要提交到 git 确保项目的版本锁定
    apps目录
    该目录下面一般放 apps 类型的项目,即需要部署上线的项目
    libs目录
    该目录下面一般放 lib 类型且不需要发布到 bnpm 的项目,例如多个项目间公用的组件库、工具库等,可以根据场景继续划分子目录。
    packages目录
    该目录下面一般放 lib 类型且需要发布到 bnpm 的项目,如果不仅仅只有当前 Monorepo 中的项目需要用到这些 lib 项目,需要发布到的 bnpm 中给其他项目使用,可以这些 lib 类型的项目放到 packages 目录下。
    plugins
    该目录下一般存放的是自定义的 Eden Mono 插件 详情:https://eden.bytedance.net/solution/monorepo/guide/advance/plugins
    eden.monorepo.json
    该文件是 Eden Mono 的配置文件,可以通过该文件配置 Eden Mono
    build.sh
    该文件是所有子项目 scm 构建的入口,一般是在eden.mono.pipeline.json 配置scm的构建 详情请见:https://eden.bytedance.net/solution/monorepo/guide/basic/scm
{
  "scene": {
    "scm": {
      // 键是 SCM 仓库的名称,指定在该SCM仓库下构建时的配置
      "eden/ies/react_gulu_demo": {
        "entries": [ // SCM构建时的入口 workspace,可以有多个
          "@eden/react-gulu-demo/server",
          "@eden/react-gulu-demo/client"
        ],
        // 在所有pipeline之前执行,例如用于初始化
        "beforeStart": ["yarn update-idl"],
        // 在所有pipeline之后执行,例如重新组织产物结构
        "afterFinish": ["cp xx xx"],
        // 自定义plugin,可以干预pipeline执行流程
        "plugins": ["./plugins/UploadSCMBuildTimePlugin"],
        // 对于 gulu/node 项目,在 SCM 构建结束后,生成该子项目的 node_modules 到 output 下
        // 该选项用于解决 gulu/node 项目的 node_modules 在 monorepo 中不能直接 copy 的问题
        "recoverDeps": {
          "projects": ["@eden/react-gulu-demo/server"]
        }
      },
    },
  }
}

实际项目中举例:

{
  "scene": {
    "gitlab": {},
    "scm": {
      "life_service/business/fe_merchant_private_mix_uni": {
        "entries": ["im"]
      },

      "life_service/business/pc_private/h5_list_aweme": {
        "entries": ["h5_aweme_send_list"]
      }
    },
    "local": {}
  }
}

常见配置解释:
scene.scm:仓库的名称
entries:在该 SCM 下构建哪些子项目,值是一个数组,数据项是子项目的名称(确保子项目的名称在 eden.monorepo.json 中注册)
recoverDeps:用于 gulu/node 项目还原 node_modules。Monorepo 下 gulu/node 项目的 node_modules 是软链(pnpm)或者不完整(yarn),直接 copy node_modules 到 output 得到的是不完整的 node_modules,不能直接用于部署。该选项将会深度递归查找配置的 projects 的所有依赖,还原到 output/node_modules 下,确保上线的 node_modules 是完整的。
更多配置详细看:https://eden.bytedance.net/solution/monorepo/configuration/eden-pipeline-json

子项目配置 eden.pipeline.js

需要进行构建、部署的 scm 子项目下需要添加 eden.pipeline.js 文件,该文件负责子项目的具体的编译流程

module.exports = {
  scene: {
    scm: {
      // 执行具体的构建流程
      build: {
        script: [
          'REGION=cn npm run build',
        ]
      },
      // 重新组织构建的产物,copy 到 output 和 output_resource 下
      upload: {
        afterScript: [
          'find ./build_cn -name "*.js.map" | xargs rm -rf',
          'mkdir -p ./output_resource/cn',
          'mkdir -p ./output/cn',
          'cp -r ./build_cn/template/* ./output/cn',
          'cp -r ./build_cn/template/* ./output_resource/cn',
          'cp -r ./build_cn/resource/* ./output_resource/cn',
        ]
      }
    }
  },
};

项目举例:


image.png

执行 scm 构建

通过 eden-mono scm 命令即可一键启动 Monorepo 的 scm 构建流程。

BUILD_REPO_NAME=SCM仓库名 eden-mono scm

scm 的构建流程

1、确定编译入口,根据 BUILD_REPO_NAME 环境变量以及 eden.mono.config.json 中的配置确定入口
2、根据入口分析依赖关系,对入口及其本地依赖安装依赖
3、在调用入口 workspaces 的 pipeline 之前,会先 build 所有入口的本地依赖
4、在入口 workspaces 下调用 eden-pipeline --scene scm,入口 workspaces 的 pipeline 的执行是并行的
5、在所有的 build.sh 执行结束后,会将 workspace 下的产物(output 和 output_resource)copy 到根目录的 output 和 output_resource 下面。如果有多个入口,那么用会包名(去掉scope)区分路径,@eden/react-gulu 和 @eden/react-spa 作为入口,构建后顶层的 output 和 output_resource 结构是:

├── output
│   ├── react-gulu
│   │   ├── app # react-gulu的output的内容
│   │   ├── ...
│   └── react-spa
│       └── resource

如何确定入口

eden-mono scm 采用默认有两种方式来确定入口package,下述两种方式优先级从高到低
1、从自定义的环境变量 CUSTOM_REPO_NAME 读取,例如在SCM构建时,设置环境变量 CUSTOM_REPO_NAME=@eden/react-gulu,如果入口有多个,可以用 , 分割
2、从配置文件读取

依赖管理

Eden Monorepo 提供了全面的依赖管理能力,提供完整子命令和参数对 Monorepo 的子项目进行依赖安装(install)、依赖添加(add)、依赖删除(rm)、依赖更新(update)等操作,并针对 Monorepo 场景对依赖管理做了大量优化。

依赖安装

使用 eden-mono install --filter <pattern> 对指定的项目及其依赖安装依赖,若没有指定项目(通过 --filter),将对所有项目安装依赖。

eden-mono install # 对所有项目 A,B,C 安装依赖
eden-mono install --filter="C" # 仅对 C 安装依赖
eden-mono install --filter="...C" # 对所有依赖 C 的项目(A、B)安装依赖(包括 C 自身)
eden-mono install --filter="A..." # 对所有 A 的依赖(B、C)安装依赖(包括 A 自身)

依赖添加

使用 eden-mono add B --filter A [--dev] [--root] [--all] 来添加依赖,将 B 作为 A 的依赖,A 是一个 Monorepo 中的子项目,B 可以是 Monorepo 中的子项目或者 npm 上的包。

eden-mono add B --filter A # 将本地项目 B 作为 A 的依赖
eden-mono add lodash --filter A # 将 lodash 作为 A 的依赖
eden-mono add lodash --filter A --filter B # 将 lodash 作为 A,B 的依赖
eden-mono add C --filter A --filter B --dev # 将 C 作为 A,B 项目的 dev 依赖
eden-mono add lodash --all # 将 lodash 添加到所有项目中(A、B、C)

删除依赖

使用 eden-mono rm B --filter A [--dev] [--root] [--all] 来移除依赖,将 B 从 A 的依赖中移除,A 是一个 Monorepo 中的子项目,B 可以是 Monorepo 中的子项目或者 npm 上的包。

eden-mono rm B --filter A # 将本地项目 B 从 A 的依赖中移除
eden-mono rm lodash --filter # 将 lodash 从 A 的依赖中移除
eden-mono rm lodash --filter A --filter B # 将 lodash 从 A,B 的依赖中移除
eden-mono rm lodash --all # 将 lodash 从所有项目中(A、B、C)移除

更新依赖

使用 eden-mono update A B [--dev] [--root] [--all] 来更新依赖,更新 A 的依赖 B,A 是一个 Monorepo 中的子项目,B 可以是 Monorepo 中的子项目或者 npm 上的包。

eden-mono update A B # 更新本地项目 A 的依赖 B
eden-mono update A lodash # 更新本地项目 A 的依赖 lodash
eden-mono update A,B lodash # 更新本地项目 A、B 的依赖 lodash
eden-mono update lodash --all # 更新所有项目中(A、B、C)中的 lodash

infraDir

常见的 Monorepo 中,根目录都会存在一个 package.json,用于一些基础依赖的安装,在这种模式下,根目录会存在 node_modules,且 node_modules 中的内容能够被子项目访问到,这时可能出现 phantom dependency 问题(即依赖没有在子项目的 package.json 中声明,但是仍然能够访问到)
会造成以下问题:
1、使用未声明的依赖的版本有隐患:比如 A 项目使用了 react,但是并没有在 A 项目的 package.json 中声明,那么 A 依赖的 react 版本更改时(比如在 root 被升级了),此时 A 项目对该升级是无感知的,这可能导致 A 项目出现 break change,而且此时很难定位问题的来源。
2、partial install 时带来意想不到的错误:比如 A 项目使用了 @types/react 的类型,但是并没有申明在 package.json 中,而 B 项目声明了,此时如果只对 A 安装依赖,那么就不会安装 @types/react,导致 A ts 编译的时候报错。
解决办法:
Eden Monorepo 设计了 infraDir 机制,用于承载最外层 node_module 的功能,配置eden.monorepo.json

{
  "config": {
    // strictNodeModules 表示启用 pnpm
    "strictNodeModules": true,
    // 使用 infra 目录承载 Monorepo 基建的能力,消除最外层的 node_modules
+    "infraDir": "infra",
    "edenMonoVersion": "2.8.0",
    "pnpmVersion": "6.15.0",
    // ...
  },
}

自动 link 本地的 package

Eden Monorepo 支持在依赖安装时自动 symlink 的本地的 workspace(而不是从 npm 上下载),便于同时调试本地的多个项目。例如 A 项目依赖了 B 项目,A 的 package.json 的内容如下:

{
  "name": "A",
  // ...
  "dependencies": {
    "B": "1.0.0"
  }
}

依赖版本检查

在项目中,多个项目间的相同的依赖版本不一致可能带来很多问题

image.png

App(业务项目) 依赖了 React 16,而 Comp(组件库)依赖了 React 17,当 App 引用了 Comp 时,此时 App 和 Comp 中使用的 React 将会是不同的实例,这会导致如下问题:
App 和 Comp 使用的是不同的 React 实例,这样导致在 Comp 中使用 React Hooks 时,React 会报错(实例不唯一等错误),导致页面白屏
React 库被 bundle 两次,导致产物的包体积增大
React 会被安装两次,导致依赖安装速度变慢
因此 Eden Monorepo 实现了 DependencyVersionChecker 用于检查依赖的版本是否统一,并提供了丰富的配置项,可以自动检查所有项目的依赖的版本是否是否统一,并提供了丰富的自动修复能力 详细看:https://eden.bytedance.net/solution/monorepo/guide/basic/checker#dependencyversioncheck

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 230,527评论 6 544
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 99,687评论 3 429
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 178,640评论 0 383
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,957评论 1 318
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,682评论 6 413
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 56,011评论 1 329
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 44,009评论 3 449
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 43,183评论 0 290
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,714评论 1 336
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,435评论 3 359
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,665评论 1 374
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 39,148评论 5 365
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,838评论 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 35,251评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,588评论 1 295
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 52,379评论 3 400
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,627评论 2 380

推荐阅读更多精彩内容