【基础帮助理解】学习Jenkins官方文档

本文是阅读了英文的Jenkins文档后的笔记以及我的一些理解和小实践。

1. Getting Started with Pipeline

official link: https://www.jenkins.io/doc/book/pipeline/getting-started/#directive-generator

创建pipeline的三种方式:第一种通过Blue ocean,第二种是通过jenkins的UI直接写脚本,第三种是通过SCM,先手动写好一个Jenkinsfile,然后commit到repository中。

通过Blue Ocean

blue ocean可以帮助建立流水线,并且自动的帮忙写pipeline,比如写Jenkinsfile。在Blue ocean中创建流水线,Jenkins会和工程的source contro repository建立安全的连接,通过blue ocean改变的Jenkinsfile的修改都会自动保存和commit到source control中。

文档:get started with Blue Ocean

安装blue ocean

用带有管理员权限的账号登陆你的Jenkins,然后主页上找到Manage Jenkins--->找到manage plugins--->可选插件(available)--->看是否会出来blue ocean,如果无,则在搜索栏输入blue ocean,然后勾选blue ocean前面的框,先后选择下载待重启后安装(推荐),这里重启指的是重启jenkins。

重启Jenkins之后,会在主页的左侧出现,打开blue ocean的一栏,则安装成功。安装成功之后,blue ocean不需要其他的配置。

进入blue ocean

点击“打开blue ocean”进入blue ocean,如果在安装blue ocean之前已经创建了流水线,那么在blue ocean的主页上会直接显示出来创建的流水线。

使用blue ocean创建pipeline

在主页点击“创建流水线”,会首先需要选择source control,比如是git,或者github,git repository支持local和remote的。在windows使用local repository在路径上会有更多的要求,措意更加推荐在blue ocean中使用remote repository。

如果需要在直接给github上的工程创建pipeline,那么需要用到GitHub access token,可以重新创建添加进去,或者使用已有的令牌。refer to “For a repository on GitHub” in this link: https://www.jenkins.io/doc/book/blueocean/creating-pipelines/

Pipeline editor

用户可以用这个编辑器来修改声明式流水线,添加stage和并行任务等。修改完成之后,编辑器会把Pipeline存为Jenkinsfile放在source code repository中。

使用这个编辑器的前提是,用户必须先使用Blue ocean创建了一个流水线或者Jekins已经有存在的流水线了。如果是修改已经存在的流水线,那么需要这个流水线必须要允许将更改推送到repository中。

使用这个editor有一些限制:

SCM-based Declarative Pipelines only

Credentials must have write permission

Does not have full parity with Declarative Pipeline

Pipeline re-ordered and comments removed

(详细使用步骤略....

经过阅读以上的文档,我了解得到的是Blue ocean对帮助管理复杂的集成过程的流水线有很大的帮助,它可以可视化显示流水线的运行状态,出错时,也能帮助到分析错到哪一步,同时它还能和repository相连接,尽管我认为这对于公司内部的项目来说,好处不大,因为不大可能直接在Blue ocean修改就把修改直接推送到repository中。

more information at https://www.jenkins.io/doc/book/blueocean/

通过Jenkins的传统UI

通过传统UI创建的Jenkinsfile会通过Jenkins自己存储,存在Jenkins的home directory中。创建基本条件:

1. 登录到Jenkins

2. 新建item中创建pipeline,在创建时填入pipeline名字时,避免使用空格等符号,因为Jenkins会用这个item名字来创建文件夹。

3. 在以下领域直接编写Jenkinsfile,可以写声明式流水线或者脚本式流水线,我写的是脚本式流水线,写完之后保存,然后自动会回到该流水线主页,可以选择build now看看运行结果。运行结束之后可以点击console output查看运行的log输出和结果。

脚本式流水线

Notes:

通过传统UI定义流水线对于测试pipeline代码片段、处理简单的流水线或者是不需要从一个repository中checkout或者clone源代码的流水线来说是很方便的。如上所述,与通过Blue Ocean或在源代码管理中定义的Jenkins文件不同,传统UI上编写的Jenkinsfile由Jenkins自己存储在Jenkins home目录中。因此,为了更好地控制和灵活性您的管道,特别是对于源代码管理中可能会增加复杂性的项目,建议使用Blue Ocean或源代码管理来定义您的文件。

通过SCM

复杂的脚本难以用过Jenkins UI 完成。因此我们想要的Jenkinsfile可以在文本编辑器或者IDE中写好,然后commit到source control中(可以选择性的包含Jenkins会build的application)。Jenkins就可以从从source control中checkout Jenkinsfile,然后执行pipeline。创建SCM中的Pipeline,首先在传统UI上创建pipeline,然后在写脚本的时候选择如下界面中的选项:

script from SCM

在SCM中选择包含写好的jenkinsfile的repository,在脚本路径中,明确指出Jenkinsfile的位置或者是文件名,位置是Jenkins从repository中checkout或者clone Jenkinsfiile的位置,这个位置填写需要遵循repository的文件结构。脚本路径的默认值是默认你的文件名是Jenkinsfile,并且默认的位置是这个repository的根目录。

更新指定的仓库时,只要配置了Pipeline的轮询触发器,就会trigger a new build。

tips:

由于流水线代码(特别是脚本化管道)是用类似Groovy的语法编写的,如果IDE语法不正确,那么请尝试插入行#!/usr/bin/env groovy位于jenkins文件的顶部,[4]footnotegroovy\u shebang:[shebang line(groovy语法)],这可能会纠正这个问题。

内置文档(Built-in Documentation)

Pipeline附带了内置的文档功能,可以更轻松地创建各种复杂的管道。这个内置文档是根据Jenkins实例中安装的插件自动生成和更新的。

代码段生成器(Snippet Generator)

内置的代码段生成器单元可以帮助生成某个步骤的代码,发现插件提供的新步骤或者是测试某一步的不同参数。通过代码段生成器生成一个步骤:

1. 找到“流水线语法”链接,点进去选择代码段生成器

代码段生成器

2. 在实例步骤中选择一个想要的步骤,比如window bat script,然后在实例步骤的下一空白面中输入想要写的bat代码的内容,填入成功之后选择生成流水线脚本。

代码段生成器

全局变量引用(Global Variable Reference)

除了Snippet Generator(只显示步骤)之外,Pipeline还提供了一个内置的“全局变量引用”。与Snippet Generator一样,它也由插件动态填充。但是,与代码段生成器不同的是,全局变量引用仅包含管道或插件提供的变量文档,这些变量可用于Pipeline。Pipeline中默认提供的变量有:

env:

公开环境变量,例如: for example: env.PATH or env.BUILD_ID。请参阅${YOUR_JENKINS_URL}/pipeline syntax/globals#env上的内置全局变量参考,以获取流水线中可用环境变量的完整且最新的列表。

params:

将为管道定义的所有参数公开为只读映射,例如:for example: params.MY_PARAM_NAME.

currentBuild:

可用于发现有关当前正在执行Pipeline的信息,其属性如: currentBuild.result, currentBuild.displayName, etc. 请参阅${YOUR_JENKINS_URL}/pipeline-syntax/globals上的内置全局变量参考,以获取currentBuild上可用的完整且最新的属性列表。

全局变量参考

声明式指令生成器(Declarative Directive Generator)

代码段生成器帮助流水线生成steps和stages,但是不包括用于定义声明式流水线的 sections and directives,声明式指令生成器就因此产生的。和代码段生成器类似,声明式指令生成器可以选择一个Declarative directive,然后在一个表格中配置它,并且为这个directive生成配置。用声明式指令生成器生成Declarative directive:

1. 打开“流水线语法”链接,然后找到Declarative Directive  Generator

2. 选择想要的directive,然后填入必要的信息,点击generate

Declarative Directive  Generator

以上便是1. Getting Started with Pipeline这个文档的主要内容,我阅读下来,对于我掌握Jenkins的架构还是有一些作用的,因为在实践中,存在的很多漏洞在与阅读文档之后,还是有补上,但是这对于我做小项目还是不够用的,因此我会参考官网文档中further reading部分,继续阅读几个文档。

2. Using a Jenkinsfile

这个文档是需要在阅读1. Getting Started with Pipeline这个文档的基础之上的,会介绍更多有用的steps,通用模板,并且演示some non-trivial Jenkinsfile examples.

official link:https://www.jenkins.io/doc/book/pipeline/jenkinsfile/

尽管也可以直接在Jenkins的传统UI上定义一个Pipeline,但创建一个存入repository的Jenkinsfile还是最好的实践,

Pipeline支持两种语法,即是声明式流水线和脚本式流水线(Declarative and Scripted Pipeline)。

创建流水线

声明式流水线,必须包含agent关键字,缺乏这个关键字配置的流水线不是有效的,不会产生任何作用。stages还有steps也是需要的,它们指示Jenkins应该执行什么和在哪一个stage应该执行。

更高级的用法是使用脚本式流水线,对于这个流水线,node关键字是非常重要的第一步,它会这个Pipeline连接一个executor和workspace,么有node关键字,这个流水线不会有任何作用。有了node之后,第一任务应该是checkout这个repository工程的source code,因为这个jenkinsfile就是从这个工程中拉下来的,所以对于流水线来说,它能更快更容易的获得source code的正确版本。

Build

对于很多工程来说,流水线的第一步就是build阶段,在这个阶段source code会被组装(assemble),编译和打包。

Jenkins有很多插件来触发不同平台上的build,比如Linux/Unix上的make指令或者windows上的bat指令执行的编译。

Test

自动化测试对于持续继承来说是很重要的,Jenkins支持一些系列的支持测试记录,报告和可视化的工具的插件来做这件事。比如使用 junit step。

Test

Deploy

根据项目或组织的需求,部署可能意味着各种步骤,可以是从将构建的工件发布到工件服务器,到将代码推送到生产系统的任何步骤。

脚本化流水线可以包括条件测试、循环、try/catch/finally块甚至函数。

3. Working with your Jenkinsfile

Using environment variables

Jenkins管道通过全局变量env公开环境变量,环境变量在Jenkinsfile的任何位置都是可用的。可从Jenkins Pipleline中访问的环境变量的完整列表记录在${YOUR_Jenkins_URL}/Pipeline syntax/globals#env中,其中包括:

BUILD_ID:当前的build ID, identical to BUILD_NUMBER for builds created in Jenkins versions 1.597+

BUILD_NUMBER:当前的build NUM,比如“153”

BUILD_TAG:jenkins的字符串-${JOB\u NAME}-${BUILD\u NUMBER}。方便地放入资源文件、jar文件等以便于识别

BUILD_URL:The URL where the results of this build can be found

JAVA_HOME:If your job is configured to use a specific JDK, this variable is set to the JAVA_HOME of the specified JDK. When this variable is set, PATH is also updated to include the bin subdirectory of JAVA_HOME

NODE_NAME: The name of the node the current build is running on. Set to 'master' for the Jenkins controller.

WORKSPACE:The absolute path of the workspace

示例:

Jenkinsfile (Declarative Pipeline)

pipeline {    

         agent any    

         stages {        

               stage('Example') {            

                    steps {                

                           echo"Running ${env.BUILD_ID} on ${env.JENKINS_URL}"}        

                       }    

                 }

    }

Setting environment variables

在Jenkins Pipeline中设置环境变量的方式有所不同,这取决于使用的是声明式流水线还是脚本式流水线。

声明式流水线支持environment指令(supports an environment directive),而脚本式的流水线必须使用withEnv。

声明式流水线environment

Notes:

An environment directive used in the top-level pipeline block will apply to all steps within the Pipeline.

An environment directive defined within a stage will only apply the given environment variables to steps within the stage.

Setting environment variables dynamically

环境变量可以在运行时设置,并且可以由shell脚本(sh)、Windows批处理脚本(bat)和PowerShell脚本(PowerShell)使用。每个脚本可以returnStatus或returnStdout。More information on scripts.

Handling credentials

####(这部分看完了之后暂时不知道具体意义体现,暂时不做笔记,日后有了一定的实践之后再来反复阅读理解。)

字符串插值(String interpolation)

Jenkins使用的是和Groovy一直的规则。Groovy的字符串插值支持可能会让许多语言新手感到困惑。Groovy支持用单引号或双引号声明字符串, for example:

def singlyQuoted ='Hello'

def doublyQuoted ="World"

只有后一个字符串支持基于美元符号($)的字符串插值,例如,只有第二种echo输出才会有读到$后的内容:

Groovy的字符串插值

字符串插值这部分主要介绍了单引号还有双引号的一些差别和在不同场景的作用,暂时不多介绍了。

Handling parameters

声明式pipeline支持out-of-the-box的参数,Pipeline可以在运行时通过parameters指令接受用户指定的参数。使用脚本式Pipeline配置参数是通过“属性”步骤(properties)完成的,该步骤可以在代码段生成器中找到。(可以通过代码段生成器来查看如何写properties)

代码段生成器之properties

如果使用build with parameters选项将Pipeline配置为接受参数,则可以作为params变量的成员访问这些参数。

将输出:Hello World!

Handling failure

声明式pipeline默认情况下通过其post部分支持的故障处理,post部分允许声明许多不同的“post条件”,例如:always、unstable、success、failure和changed。

声明式流水线的故障处理

脚本式流水线依赖于Groovy内置的try/catch/finally语义来处理流水线执行期间的失败。

Using multiple agents

Jenkins支持多个agent。

在下面的示例中,“构建”阶段将在一个代理上执行,并且在“测试”阶段期间,构建结果将在两个后续代理(分别标记为“linux”和“windows”)上重用。

Mutiple agents

Optional step arguments

Pipeline遵循Groovy语言的惯例,允许在方法参数周围省略括号。

许多Pipeline步骤还使用命名参数语法(the named-parameter)作为在Groovy中创建映射的缩写,Groovy使用语法[key1:value1,key2:value2]。做出如下功能等效的陈述:

示例

为方便起见,当调用仅使用一个参数(或仅使用一个强制参数)的步骤时,可以省略参数名称,例如:

示例

Advanced Scripted Pipeline

脚本式流水线是一种基于Groovy的领域专用语言(domain-specific language),大多数Groovy语法可以在脚本式流水线中使用,无需修改。

Parallel execution

Mutiple agents的示例代码显示以线性序列的形式跨两个不同的平台运行测试。实际上,如果make check执行需要30分钟才能完成,“Test”阶段现在需要60分钟才能完成!但是Pipeline内置了并行执行脚本式Pipeline部分的功能,这个功能在parallel步骤中实现。

parallel step

以上代码不再在“linux”和“windows”标记的节点上串行执行测试,而是假设Jenkins环境中存在必要的容量,测试将并行执行。

4. Branches and Pull Requests

暂略

5. Using Docker with Pipeline

许多组织使用Docker跨机器统一其构建和测试环境,并提供部署应用程序的有效机制。从Pipeline版本2.5和更高版本开始,Pipeline内置了从文件中与Docker交互的支持。

暂略(因为现在不考虑用这个)

6. Extending with Shared Libraries 

随着一个组织中越来越多的项目采用Pipeline,很可能会出现通用模式。通常,在不同的项目之间共享Pipeline的一部分是有用的,这样可以减少冗余并保持代码“DRY”(reduce redundancies and keep code "DRY" )。

Pipeline支持创建“共享库”,这些库可以在外部源代码管理存储库中定义并加载到现有Pipeline中。

7. Defining Shared Libraries

共享库由名称、源代码检索方法(如由SCM)和默认版本(可选)定义。名称应该是一个简短的标识符,因为它将在脚本中使用。版本可以是该SCM可以理解的任何内容;例如,分支、标记和commits( branches, tags, and commit hashes )都适用于Git。您还可以声明脚本是否需要显式请求该库,或者默认情况下是否存在该库。此外,如果在Jenkins配置中指定一个版本,可以阻止脚本选择不同的版本。指定SCM的最佳方法是使用SCM插件,这个插件现在已经有独立的更新。在撰写本文时,最新版本的Git和Subversion插件支持这种模式;其他插件也应该支持这种模式。如果SCM插件尚未集成,可以选择Legacy SCM并选择任何提供的插件。在这种情况下,需要在SCM配置中包括 $ {library.yourLibName.version} 以便在checkout 插件时通过这个变量选择所需的版本。

例如,对于Subversion,可以将存储库URL设为 svnserver/project/${library.yourLibName.version},然后使用trunk或branchs/dev或tags/1.0等版本。

Directory structure

shared directory

暂略(内容略深,需要时再回顾)


8. Pipeline Syntax (流水线语法)

Differences between top and stage level Agents

Top Level Agents

在流水线的最外层定义了agent,在进入到这个agent之后调用选项,例如,这个timeout是在myAgent的这个agent上执行的

top-level agent

Stage Agents

声明在stage内的agent,调用选项会在进入到这个这个agent之前或者检查任何when条件之前执行。例如,会在进入到这个agent之前执行timeout

stage-level agent

此timeout将包括agent设置时间,所以在agent分配延迟的情况下,管道可能会失败。

Parameters

any

none

label: A string. The label or label condition on which to run the Pipeline or individual stage.

node: agent { node { label 'labelName' } } behaves the same as agent { label 'labelName' }, but node allows for additional options (such as customWorkspace).

timestamps: 在流水线运行生成的所有控制台输出前面加上打印该行的时间。例如:options{timestamps()}

timeout:为某个stages设置一个超时时间段,在此之后Jenkins应该中止这个stage。例如:options{timeout(time:1,unit:'HOURS')}

retry:如果失败,按指定次数重试此步骤。例如:options{retry(3)}

paramters:

paramters

Jenkins cron syntax:Jenkins cron语法遵循cron实用程序的语法(略有不同)。具体来说,每行由5个字段组成,用制表符或空格分隔,for example,

cron语法

stage:stage指令位于stages部分,应该包含steps部分、可选agent部分或其他特定于阶段的指令。实际上,流水线完成的所有实际工作都将封装在一个或多个stage指令中。

parallel:流水线中的Stages中可以有一个并行部分,其中包含了要并行运行的列表。需要注意的是,一个stage必须只有steps、stage、parallel或matrix中的一个。如果stage指令嵌套在parallel或matrix块中,则无法在stage指令中嵌套parallel或matrix块。但是,parallel或matrix块中的stage指令可以使用stage的所有其他功能,包括agent、tool、when等。通过将failFast true添加到包含并行进程的进程中,可以在任何一个进程失败时强制所有并行进程中止。添加failfast的另一个选项是向管道定义添加一个选项:parallelsAlwaysFailFast()。

stash/unstash:暂时将文件存储起来以便在node/workspace的中使用,后需通过unstash方式调用,stash会将文件打包成一个tar包来,所以大文件时会耗CPU,而且stash有文件大小限制,尽量在100M以下。除了stash方法,还有一个archive的方法也可以存文件。在Jenkins 2.x以上,已经改为archiveArtifacts命令。

archiveArtifacts:archiveArtifacts捕获与include模式(**/target/*.jar)匹配的构建文件,并将其保存到Jenkins控制器以供以后检索。

archive命令

但是这二者区别是:

 stash会在pipeline任务结束后将文件全部删除,而archive会一直保存文件,且在jenkins页面上显示

Scripted Pipiline(脚本式流水线)

脚本式流水线与声明式流水线一样,构建在底层流水线子系统之上。与声明式流水线不同,脚本式流水线实际上是用Groovy构建的通用DSL[2]。Groovy语言提供的大部分功能都提供给脚本式流水线的用户,这意味着它可以是一个非常有表现力和灵活性的工具,用户可以使用它来编写连续交付流水线

Flow Control

像Groovy或其他语言中的大多数传统脚本一样,脚本式流水线是从jenkins文件的顶部向下顺序执行的。因此,提供流控制依赖于Groovy表达式,例如if/else条件语句。例如:

if/else 语句

另一种管理脚本式流水线流控制的方法是使用Groovy的异常处理支持。当步骤由于任何原因失败时,可以抛出异常。错误处理行为必须使用Groovy中的try/catch/finally块,例如:

try/catch/finally

9. Pipeline Best Practices

1. 使用Groovy代码连接一组操作,而不是作为流水线的主要功能。换句话说,不要依赖流水线功能(Groovy或管道步骤)来推动构建过程,而是使用单个步骤(如sh)来完成构建的多个部分。随着流水线复杂性的增加(Groovy代码的数量、使用的步骤的数量等),流水线需要控制器上更多的资源(CPU、内存、存储)。将流水线视为完成构建的工具,而不是构建的核心。

2. 避免在流水线中使用复杂的Groovy语法。如避免使用JsonSlurper和HttpRequest等。

3. 减少相似步骤的重复。尽可能多地将流水线上步骤组合成单个步骤,以减少管道执行引擎本身造成的开销。例如,如果连续运行三个shell步骤,则必须启动和停止其中的每一个步骤,这需要创建和清理代理和控制器上的连接和资源。但是,如果将所有命令放在一个shell步骤中,则只需要启动和停止一个步骤。

4. 不要调用Jenkins.getInstance。

5. Using shared libraries

6. Do not override built-in Pipeline steps

7. Avoiding large global variable declaration files,拥有大型变量声明文件可能需要大量内存,但几乎没有好处,因为无论是否需要用到变量,都会为每个流水线加载该文件。建议创建只包含与当前执行相关的变量的小变量文件

8. Avoiding very large shared libraries

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

推荐阅读更多精彩内容