深入解析SpringBoot java-jar命令行启动原理

在spring boot里,很吸引人的一个特性是可以直接把应用打包成为一个jar/war,然后这个jar/war是可以直接启动的,而不需要另外配置一个Web Server。那么spring boot如何启动的呢?今天我们就来一起探究一下它的原理。首先我们来创建一个基本的spring boot工程来帮助我们分析,本次spring boot版本为 2.2.5.RELEASE。

// SpringBootDemo.java
@SpringBootApplication
public class SpringBootDemo {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootDemo.class);
    }

}

下面是pom依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <finalName>springboot-demo</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

创建完工程后,执行maven的打包命令,会生成两个jar文件:

springboot-demo.jar

springboot-demo.jar.original

其中springboot-demo.jar.original是默认的maven-jar-plugin生成的包。springboot-demo.jar是spring boot maven插件生成的jar包,里面包含了应用的依赖,以及spring boot相关的类。下面称之为executable jar或者fat jar。后者仅包含应用编译后的本地资源,而前者引入了相关的第三方依赖,这点从文件大小也能看出。

图1

关于executable jar,spring boot官方文档 中是这样解释的。

Executable jars (sometimes called “fat jars”) are archives containing your compiled classes along with all of the jar dependencies that your code needs to run.

Executable jar(有时称为“fat jars”)是包含您的已编译类以及代码需要运行的所有jar依赖项的归档文件。

Java does not provide any standard way to load nested jar files (that is, jar files that are themselves contained within a jar). This can be problematic if you need to distribute a self-contained application that can be run from the command line without unpacking.

Java没有提供任何标准的方式来加载嵌套的jar文件(即,它们本身包含在jar中的jar文件)。如果您需要分发一个自包含的应用程序,而该应用程序可以从命令行运行而无需解压缩,则可能会出现问题。

To solve this problem, many developers use “shaded” jars. A shaded jar packages all classes, from all jars, into a single “uber jar”. The problem with shaded jars is that it becomes hard to see which libraries are actually in your application. It can also be problematic if the same filename is used (but with different content) in multiple jars.

为了解决这个问题,许多开发人员使用 shaded jars。 一个 shaded jar 将来自所有jar的所有类打包到一个 uber(超级)jar 中。 shaded jars的问题在于,很难查看应用程序中实际包含哪些库。 如果在多个jar中使用相同的文件名(但具有不同的内容),也可能会产生问题。

Spring Boot takes a different approach and lets you actually nest jars directly.

Spring Boot采用了另一种方法,实际上允许您直接嵌套jar。

简单来说,Java标准中是没有来加载嵌套的jar文件,就是jar中的jar的方式的,为了解决这一问题,很多开发人员采用shaded jars,但是这种方式会有一些问题,而spring boot采用了不同于shaded jars的另一种方式。

Executable Jar 文件结构

那么spring boot具体是如何实现的呢?带着这个疑问,先来查看spring boot打好的包的目录结构(不重要的省略掉):

图6

可以发现,文件目录遵循了下面的规范:

Application classes should be placed in a nested BOOT-INF/classes directory. Dependencies should be placed in a nested BOOT-INF/lib directory.

应用程序类应该放在嵌套的BOOT-INF/classes目录中。依赖项应该放在嵌套的BOOT-INF/lib目录中。

我们通常在服务器中使用java -jar 命令启动我们的应用程序,在Java官方文档是这样描述的:

Executes a program encapsulated in a JAR file. The filename argument is the name of a JAR file with a manifest that contains a line in the form Main-Class:classname that defines the class with the public static void main(String[] args) method that serves as your application's starting point.

执行封装在JAR文件中的程序。filename参数是具有清单的JAR文件的名称,该清单包含Main-Class:classname形式的行,该行使用公共静态void main(String [] args)方法定义该类,该方法充当应用程序的起点。

When you use the -jar option, the specified JAR file is the source of all user classes, and other class path settings are ignored.

使用-jar选项时,指定的JAR文件是所有用户类的源,而其他类路径设置将被忽略。

简单说就是,java -jar 命令引导的具体启动类必须配置在清单文件 MANIFEST.MF 的 Main-Class 属性中,该命令用来引导标准可执行的jar文件,读取的是 MANIFEST.MF文件的Main-Class 属性值,Main-Class 也就是定义包含了main方法的类代表了应用程序执行入口类。

那么回过头再去看下之前打包好、解压之后的文件目录,找到 /META-INF/MANIFEST.MF 文件,看下元数据:

Manifest-Version: 1.0
Implementation-Title: spring-boot-demo
Implementation-Version: 1.0-SNAPSHOT
Start-Class: com.example.spring.boot.demo.SpringBootDemo
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.2.5.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class: org.springframework.boot.loader.JarLauncher

可以看到Main-Class是org.springframework.boot.loader.JarLauncher ,说明项目的启动入口并不是我们自己定义的启动类,而是JarLauncher。而我们自己的项目引导类com.example.spring.boot.demo.SpringBootDemo,定义在了Start-Class属性中,这个属性并不是Java标准的MANIFEST.MF文件属性。

spring-boot-maven-plugin 打包过程

我们并没有添加org.springframework.boot.loader下的这些类的依赖,那么它们是如何被打包在 FatJar 里面的呢?这就必须要提到spring-boot-maven-plugin插件的工作机制了 。对于每个新建的 spring boot工程,可以在其 pom.xml 文件中看到如下插件:

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

这个是 SpringBoot 官方提供的用于打包 FatJar 的插件,org.springframework.boot.loader 下的类其实就是通过这个插件打进去的;

当我们执行package命令的时候会看到下面这样的日志:

[INFO] --- spring-boot-maven-plugin:2.2.5.RELEASE:repackage (repackage) @ spring-boot-demo ---
[INFO] Replacing main artifact with repackaged archive

repackage目标对应的将执行到org.springframework.boot.maven.RepackageMojo#execute,该方法的主要逻辑是调用了org.springframework.boot.maven.RepackageMojo#repackage

// RepackageMojo.java
private void repackage() throws MojoExecutionException {
    // 获取使用maven-jar-plugin生成的jar,最终的命名将加上.orignal后缀
    Artifact source = getSourceArtifact();
    // 最终文件,即Fat jar
    File target = getTargetFile();
    // 获取重新打包器,将重新打包成可执行jar文件
    Repackager repackager = getRepackager(source.getFile());  
    // 查找并过滤项目运行时依赖的jar
    Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters()));
    // 将artifacts转换成libraries
    Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog());
    try {
        // 提供Spring Boot启动脚本
        LaunchScript launchScript = getLaunchScript();
        // 执行重新打包逻辑,生成最后fat jar
        repackager.repackage(target, libraries, launchScript);
    }
    catch (IOException ex) {
        throw new MojoExecutionException(ex.getMessage(), ex);
    }
    // 将source更新成 xxx.jar.orignal文件
    updateArtifact(source, target, repackager.getBackupFile());
}

// 继续跟踪getRepackager这个方法,知道Repackager是如何生成的,也就大致能够推测出内在的打包逻辑。
private Repackager getRepackager(File source) {
    Repackager repackager = new Repackager(source, this.layoutFactory);
    repackager.addMainClassTimeoutWarningListener(new LoggingMainClassTimeoutWarningListener());
    // 设置main class的名称,如果不指定的话则会查找第一个包含main方法的类,
    // repacke最后将会设置org.springframework.boot.loader.JarLauncher
    repackager.setMainClass(this.mainClass);
    if (this.layout != null) {
        getLog().info("Layout: " + this.layout);
        repackager.setLayout(this.layout.layout());
    }
    return repackager;
}

repackager设置了 layout方法的返回对象,也就是org.springframework.boot.loader.tools.Layouts.Jar

/**
 * Executable JAR layout.
 */
public static class Jar implements RepackagingLayout {

    @Override
    public String getLauncherClassName() {
        return "org.springframework.boot.loader.JarLauncher";
    }

    @Override
    public String getLibraryDestination(String libraryName, LibraryScope scope) {
        return "BOOT-INF/lib/";
    }

    @Override
    public String getClassesLocation() {
        return "";
    }

    @Override
    public String getRepackagedClassesLocation() {
        return "BOOT-INF/classes/";
    }

    @Override
    public boolean isExecutable() {
        return true;
    }

}

layout我们可以将之翻译为文件布局,或者目录布局,代码一看清晰明了,同时我们又发现了定义在MANIFEST.MF 文件的Main-Class属性org.springframework.boot.loader.JarLauncher了,看来我们的下面的重点就是研究一下这个JarLauncher了。

JarLauncher构造过程

因为org.springframework.boot.loader.JarLauncher的类是在spring-boot-loader中的,关于spring-boot-loader,spring boot的github上是这样介绍的:

Spring Boot Loader provides the secret sauce that allows you to build a single jar file that can be launched using java -jar. Generally you will not need to use spring-boot-loader directly, but instead work with the Gradle or Maven plugin.

Spring Boot Loader提供了秘密工具,可让您构建可以使用java -jar启动的单个jar文件。通常,您不需要直接使用spring-boot-loader,而可以使用Gradle或Maven插件。

但是若想在IDEA中来看源码,需要在pom文件中引入如下配置:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-loader</artifactId>
    <scope>provided</scope>
</dependency>

找到org.springframework.boot.loader.JarLauncher类

// JarLauncher.java
public class JarLauncher extends ExecutableArchiveLauncher {

    // BOOT-INF/classes/
    static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";
    // BOOT-INF/lib/
    static final String BOOT_INF_LIB = "BOOT-INF/lib/";

    public JarLauncher() {
    }

    protected JarLauncher(Archive archive) {
        super(archive);
    }

    @Override
    protected boolean isNestedArchive(Archive.Entry entry) {
        if (entry.isDirectory()) {
            return entry.getName().equals(BOOT_INF_CLASSES);
        }
        return entry.getName().startsWith(BOOT_INF_LIB);
    }
    // main方法
    public static void main(String[] args) throws Exception {
        new JarLauncher().launch(args);
    }

}

可以发现,JarLauncher定义了BOOT_INF_CLASSES和BOOT_INF_LIB两个常量,正好就是前面我们解压之后的两个文件目录。JarLauncher包含了一个main方法,作为应用的启动入口。

从 main 来看,只是构造了一个 JarLauncher对象,然后执行其 launch 方法 。再来看一下JarLauncher的继承结构:

图2

构造JarLauncherd对象时会调用父类ExecutableArchiveLauncher的构造方法:

// ExecutableArchiveLauncher.java
public ExecutableArchiveLauncher() {
    try {
        // 构造 archive 对象
        this.archive = createArchive();
    }
    catch (Exception ex) {
        throw new IllegalStateException(ex);
    }
}
// 构造 archive 对象
protected final Archive createArchive() throws Exception {
    ProtectionDomain protectionDomain = getClass().getProtectionDomain();
    CodeSource codeSource = protectionDomain.getCodeSource();
    URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
    // 这里就是拿到当前的 classpath 的绝对路径
    String path = (location != null) ? location.getSchemeSpecificPart() : null;
    if (path == null) {
        throw new IllegalStateException("Unable to determine code source archive");
    }
    File root = new File(path);
    if (!root.exists()) {
        throw new IllegalStateException("Unable to determine code source archive from " + root);
    }
    // 将构造的archive 对象返回
    return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}

Archive

这里又需要我们先来了解一下Archive相关的概念。

  • archive即归档文件,这个概念在linux下比较常见
  • 通常就是一个tar/zip格式的压缩包
  • jar是zip格式
public abstract class Archive {
    public abstract URL getUrl();
    public String getMainClass();
    public abstract Collection<Entry> getEntries();
    public abstract List<Archive> getNestedArchives(EntryFilter filter);
}

Archive是在spring boot里抽象出来的用来统一访问资源的接口。该接口有两个实现,分别是ExplodedArchive和JarFileArchive。前者是一个文件目录,后者是一个jar,都是用来在文件目录和jar中寻找资源的,这里看到JarLauncher既支持jar启动,也支持文件系统启动,实际上我们在解压后的文件目录里执行 java org.springframework.boot.loader.JarLauncher 命令也是可以正常启动的。

图3

在FatJar中,使用的是后者。Archive都有一个自己的URL,比如

jar:file:/D:/java/workspace/spring-boot-bootstarp-demo/spring-boot-demo/target/springboot-demo.jar!

Archive类还有一个getNestedArchives方法,下面还会用到这个方法,这个方法实际返回的是springboot-demo.jar/lib下面的jar的Archive列表。它们的URL是:

jar:file:/D:/java/workspace/spring-boot-bootstarp-demo/spring-boot-demo/target/springboot-demo.jar!/BOOT-INF/lib/spring-boot-starter-web-2.2.5.RELEASE.jar!

jar:file:/D:/java/workspace/spring-boot-bootstarp-demo/spring-boot-demo/target/springboot-demo.jar!/BOOT-INF/lib/spring-boot-starter-2.2.5.RELEASE.jar!

jar:file:/D:/java/workspace/spring-boot-bootstarp-demo/spring-boot-demo/target/springboot-demo.jar!/BOOT-INF/lib/spring-boot-2.2.5.RELEASE.jar!

jar:file:/D:/java/workspace/spring-boot-bootstarp-demo/spring-boot-demo/target/springboot-demo.jar!/BOOT-INF/lib/spring-boot-autoconfigure-2.2.5.RELEASE.jar!/

省略......

launch()执行流程

archive构造完成后就该执行JarLauncher的launch方法了,这个方法定义在了父类的Launcher里:

// Launcher.java
protected void launch(String[] args) throws Exception {
    /*
     * 利用 java.net.URLStreamHandler 的扩展机制注册了SpringBoot的自定义的可以解析嵌套jar的协议。
     * 因为SpringBoot FatJar除包含传统Java Jar中的资源外还包含依赖的第三方Jar文件
     * 当SpringBoot FatJar被java -jar命令引导时,其内部的Jar文件是无法被JDK的默认实现
     * sun.net.www.protocol.jar.Handler当做classpath的,这就是SpringBoot的自定义协议的原因。
     */
    JarFile.registerUrlProtocolHandler();
    // 通过 classpath 来构建一个 ClassLoader
    ClassLoader classLoader = createClassLoader(getClassPathArchives()); // 1
    launch(args, getMainClass(), classLoader); // 2
}

重点关注下createClassLoader(getClassPathArchives()) 构建ClassLoader的逻辑,首先调用getClassPathArchives()方法返回值作为参数,该方法为抽象方法,具体实现在子类ExecutableArchiveLauncher中:

// ExecutableArchiveLauncher.java
@Override
protected List<Archive> getClassPathArchives() throws Exception {
    List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
    postProcessClassPathArchives(archives);
    return archives;
}

该方法会执行Archive接口定义的getNestedArchives方法返回的与指定过滤器匹配的条目的嵌套存档列表。从上文可以发现,这里的archive其实就是JarFileArchive ,传入的过滤器是JarLauncher#isNestedArchive方法引用

// JarLauncher.java
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
    // entry是文件目录时,必须是我们自己的业务类所在的目录 BOOT-INF/classes/
    if (entry.isDirectory()) {
        return entry.getName().equals(BOOT_INF_CLASSES);
    }
    // entry是Jar文件时,需要在依赖的文件目录 BOOT-INF/lib/下面
    return entry.getName().startsWith(BOOT_INF_LIB);
}

getClassPathArchives方法通过过滤器将BOOT-INF/classes/和BOOT-INF/lib/下的嵌套存档作为List<Archive>返回参数传入createClassLoader方法中。

// Launcher.java
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
    List<URL> urls = new ArrayList<>(archives.size());
    for (Archive archive : archives) {
        // 前面说到,archive有一个自己的URL的,获得archive的URL放到list中
        urls.add(archive.getUrl());
    }
    // 调用下面的重载方法
    return createClassLoader(urls.toArray(new URL[0]));
}

// Launcher.java
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
    return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}

createClassLoader()方法目的是为得到的URL们创建一个类加载器 LaunchedURLClassLoader,构造时传入了当前Launcher的类加载器作为其父加载器,通常是系统类加载器。下面重点看一下LaunchedURLClassLoader的构造过程:

// LaunchedURLClassLoader.java
public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
    super(urls, parent);
}

LaunchedURLClassLoader是spring boot自己定义的类加载器,继承了JDK的URLClassLoader并重写了loadClass方法,也就是说它修改了默认的类加载方式,定义了自己的类加载规则,可以从前面得到的 List<Archive>中加载依赖包的class文件了 。

LaunchedURLClassLoader创建完成后,我们回到Launcher中,下一步就是执行launch的重载方法了。

// Launcher.java
launch(args, getMainClass(), classLoader);

在此之前,会调用getMainClass方法并将其返回值作为参数。

getMainClass的实现在Launcher的子类ExecutableArchiveLauncher中:

// ExecutableArchiveLauncher.java
@Override
protected String getMainClass() throws Exception {
    // 从 archive 中拿到 Manifest文件
    Manifest manifest = this.archive.getManifest();
    String mainClass = null;
    if (manifest != null) {
        // 就是MANIFEST.MF 文件中定义的Start-Class属性,也就是我们自己写的com.example.spring.boot.demo.SpringBootDemo这个类
        mainClass = manifest.getMainAttributes().getValue("Start-Class");
    }
    if (mainClass == null) {
        throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
    }
    // 返回mainClass
    return mainClass;
}

得到mainClass后,执行launch的重载方法:

// Launcher.java
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception {
    // 将自定义的LaunchedURLClassLoader设置为当前线程上下文类加载器
    Thread.currentThread().setContextClassLoader(classLoader);
    // 构建一个 MainMethodRunner 实例对象来启动应用
    createMainMethodRunner(mainClass, args, classLoader).run();
}

// Launcher.java
protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
    return new MainMethodRunner(mainClass, args);
}

MainMethodRunner对象构建完成后,调用它的run方法:

// MainMethodRunner.java
public void run() throws Exception {
    // 使用当前线程上下文类加载器也就是自定义的LaunchedURLClassLoader来加载我们自己写的com.example.spring.boot.demo.SpringBootDemo这个类
    Class<?> mainClass = Thread.currentThread().getContextClassLoader().loadClass(this.mainClassName);
    // 找到SpringBootDemo的main方法
    Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
    // 最后,通过反射的方式调用main方法
    mainMethod.invoke(null, new Object[] { this.args });
}

至此,我们自己的main方法开始被调用,所有我们自己的应用程序类文件均可通过/BOOT-INF/classes加载,所有依赖的第三方jar均可通过/BOOT-INF/lib加载,然后就开始了spring boot的启动流程了。

debug技巧

以上就是spring boot通过java -jar命令启动的原理了,了解了原理以后我们可不可以通过debug来进一步加深一下理解呢?通常我们在IDEA里启动时是直接运行main方法,因为依赖的Jar都让IDEA放到classpath里了,所以spring boot直接启动就完事了,并不会通过上面的方式来启动。不过我们可以通过配置IDEA的 run/debug configurations 配置 JAR Application 来实现通过Jar方式启动。

图4

当我们做了以上设置后,就可以来方便的在IDEA里来dubug源码了。

图5

小结

本文通过JarLauncher为切入点,介绍了spring boot的java -jar的启动方式,阐述了JarLauncher启动的基本工作原理,同时简单介绍了相关的spring-boot-maven-plugin插件和Archive、LaunchedURLClassLoader等相关概念,希望能够对大家的理解有所帮助。

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

推荐阅读更多精彩内容