违反ClassLoader双亲委派机制三部曲第二部——Tomcat类加载机制

前言:
本文是基于 ClassLoader双亲委派机制源码分析 了解过正统JDK类加载机制及其实现原理的基础上,进而分析这种思想如何应用到Tomcat这个web容器中,从源码的角度对 违反ClassLoader双亲委派机制三部曲之首部——JDBC驱动加载 中提出的Tomcat是如何完成多个web应用之间相互隔离,又如何保证多个web应用都能加载到基础类库的问题进行了解答,我们按如下的思路布局整篇文章:

  • 先给出Tomcat整体的类加载体系结构
  • 通过查看源码验证该类加载体系的正确性
  • 总结Tomcat如何设计保证多应用隔离
    另外本文是基于Tomcat7的源码进行分析的,因此读者最好先搭建一套基于Tomcat7的环境,以便查阅源码以及运行调试,可以按照该文章的方式进行搭建:Tomcat源码导入Idea

Tomcat类加载体系结构

图1. Tomcat整体类加载体系结构

Tomcat本身也是一个java项目,因此其也需要被JDK的类加载机制加载,也就必然存在引导类加载器、扩展类加载器和应用(系统)类加载器。Tomcat自身定义的类加载器主要由图中下半部分组成,Common ClassLoader作为Catalina ClassLoaderShared ClassLoader的parent,而Shared ClassLoader又可能存在多个children类加载器WebApp ClassLoader,一个WebApp ClassLoader实际上就对应一个Web应用,那Web应用就有可能存在Jsp页面,这些Jsp页面最终会转成class类被加载,因此也需要一个Jsp的类加载器,就是图中的JasperLoder
需要注意的是,在代码层面Catalina ClassLoaderShared ClassLoaderCommon ClassLoader对应的实体类实际上都是URLClassLoader或者SecureClassLoader,一般我们只是根据加载内容的不同和加载父子顺序的关系,在逻辑上划分为这三个类加载器;而WebApp ClassLoaderJasperLoader都是存在对应的类加载器类的
下面我们从源码设计的角度验证图中类加载器的设计

源码分析Tomcat类加载机制

Tomcat的启动入口在Bootstrap.class

图2. Tomcat启动入口

其中初始化类加载器的流程在bootstrap.init();中,如下“代码清单1

public void init()
        throws Exception
    {

        // Set Catalina path
        setCatalinaHome();
        setCatalinaBase();

        // (1)   初始化 classLoader
        initClassLoaders();
        Thread.currentThread().setContextClassLoader(catalinaLoader);

        SecurityClassLoad.securityClassLoad(catalinaLoader);

        // Load our startup class and call its process() method
        if (log.isDebugEnabled())
            log.debug("Loading startup class");

        //加载 org.apache.catalina.startup.Catalina class
        Class<?> startupClass =
            catalinaLoader.loadClass
            ("org.apache.catalina.startup.Catalina");

        // (2)  实例化 Catalina 实例
        Object startupInstance = startupClass.newInstance();

        // Set the shared extensions class loader
        if (log.isDebugEnabled())
            log.debug("Setting startup class properties");

        String methodName = "setParentClassLoader";
        Class<?> paramTypes[] = new Class[1];
        paramTypes[0] = Class.forName("java.lang.ClassLoader");
        Object paramValues[] = new Object[1];
        paramValues[0] = sharedLoader;
        Method method =
            startupInstance.getClass().getMethod(methodName, paramTypes);
        method.invoke(startupInstance, paramValues);

        catalinaDaemon = startupInstance;

    }

(1)处注释的代码主要进行类加载的初始化以及形成类加载器的关系初始化,继续跟进

图3. initClassLoaders()方法

这里红线处的代码实际上创建了三个ClassLoader对象,其名称和Tomcat类加载关系图中的类加载器高度一致,那么我们猜测createClassLoader(String,ClassLoader)方法可能就是创建Tomcat自定义类加载器的方法之一,继续往下看 “ 代码清单2

private ClassLoader createClassLoader(String name, ClassLoader parent)
        throws Exception {
        // (1) 根据名称查找特定的配置
        String value = CatalinaProperties.getProperty(name + ".loader");
        if ((value == null) || (value.equals("")))
            return parent;

        value = replace(value);

        List<Repository> repositories = new ArrayList<Repository>();

        StringTokenizer tokenizer = new StringTokenizer(value, ",");
        while (tokenizer.hasMoreElements()) {
            String repository = tokenizer.nextToken().trim();
            if (repository.length() == 0) {
                continue;
            }

            // Check for a JAR URL repository
            try {
                @SuppressWarnings("unused")
                URL url = new URL(repository);
                repositories.add(
                        new Repository(repository, RepositoryType.URL));
                continue;
            } catch (MalformedURLException e) {
                // Ignore
            }

            // Local repository
            if (repository.endsWith("*.jar")) {
                repository = repository.substring
                    (0, repository.length() - "*.jar".length());
                repositories.add(
                        new Repository(repository, RepositoryType.GLOB));
            } else if (repository.endsWith(".jar")) {
                repositories.add(
                        new Repository(repository, RepositoryType.JAR));
            } else {
                repositories.add(
                        new Repository(repository, RepositoryType.DIR));
            }
        }
        // (2) 类加载器工厂创建特定类加载器
        return ClassLoaderFactory.createClassLoader(repositories, parent);
    }

代码清单中(1)处注释是根据上图中传递的“名称”加上后缀.loader去某个配置文件加载文件,为了突出重点,这里直接给出结论,其加载的内容为/org/apache/catalina/startup/catalina.properties,比如要加载 common.loader对应的value,其在文件中的值为${catalina.base}/lib,${catalina.base}/lib/*.jar,${catalina.home}/lib,${catalina.home}/lib/*.jar,也就是说Common ClassLoader要加载的路径是这些,是Tomcat运行要使用的公共组件,比如servlet-api.jarcatalina.jar等;而我们发现当要加载server.loadershared.loader时,其key在配置文件中的value为空,也就是说,默认情况下Catalina ClassLoader和Shared ClassLoader(Tomcat整体类加载体系结构图中红色虚线内)都不存在,只有Common ClassLoader
方法中的第二个参数表示创建类加载器的父类加载器是哪个,再看initClassLoaders()方法图中代码,在创建catalinaLoadersharedLoader时,父类加载器传入的实际上就是commonLoader,以此可以验证图1中Catalina ClassLoaderShared ClassLoaderCommon ClassLoader的父子关系。而common ClassLoader的父类加载器参数传递的为null,为什么null就会导致该类加载器的父类加载器为System ClassLoader呢?我们需要进入代码清单2中看注释(2)处标识的代码 代码清单3

    public static ClassLoader createClassLoader(List<Repository> repositories,
                                                final ClassLoader parent)
        throws Exception {

        if (log.isDebugEnabled())
            log.debug("Creating new class loader");

        // Construct the "class path" for this class loader
        Set<URL> set = new LinkedHashSet<URL>();
        // 加载指定路径下的资源对象
        if (repositories != null) {
            for (Repository repository : repositories)  {
                if (repository.getType() == RepositoryType.URL) {
                    URL url = buildClassLoaderUrl(repository.getLocation());
                    if (log.isDebugEnabled())
                        log.debug("  Including URL " + url);
                    set.add(url);
                } else if (repository.getType() == RepositoryType.DIR) {
                    File directory = new File(repository.getLocation());
                    directory = directory.getCanonicalFile();
                    if (!validateFile(directory, RepositoryType.DIR)) {
                        continue;
                    }
                    URL url = buildClassLoaderUrl(directory);
                    if (log.isDebugEnabled())
                        log.debug("  Including directory " + url);
                    set.add(url);
                } else if (repository.getType() == RepositoryType.JAR) {
                    File file=new File(repository.getLocation());
                    file = file.getCanonicalFile();
                    if (!validateFile(file, RepositoryType.JAR)) {
                        continue;
                    }
                    URL url = buildClassLoaderUrl(file);
                    if (log.isDebugEnabled())
                        log.debug("  Including jar file " + url);
                    set.add(url);
                } else if (repository.getType() == RepositoryType.GLOB) {
                    File directory=new File(repository.getLocation());
                    directory = directory.getCanonicalFile();
                    if (!validateFile(directory, RepositoryType.GLOB)) {
                        continue;
                    }
                    if (log.isDebugEnabled())
                        log.debug("  Including directory glob "
                            + directory.getAbsolutePath());
                    String filenames[] = directory.list();
                    if (filenames == null) {
                        continue;
                    }
                    for (int j = 0; j < filenames.length; j++) {
                        String filename = filenames[j].toLowerCase(Locale.ENGLISH);
                        if (!filename.endsWith(".jar"))
                            continue;
                        File file = new File(directory, filenames[j]);
                        file = file.getCanonicalFile();
                        if (!validateFile(file, RepositoryType.JAR)) {
                            continue;
                        }
                        if (log.isDebugEnabled())
                            log.debug("    Including glob jar file "
                                + file.getAbsolutePath());
                        URL url = buildClassLoaderUrl(file);
                        set.add(url);
                    }
                }
            }
        }

        // Construct the class loader itself
        final URL[] array = set.toArray(new URL[set.size()]);
        if (log.isDebugEnabled())
            for (int i = 0; i < array.length; i++) {
                log.debug("  location " + i + " is " + array[i]);
            }
        //  返回创建的类加载器
        return AccessController.doPrivileged(
                new PrivilegedAction<URLClassLoader>() {
                    @Override
                    public URLClassLoader run() {
                        if (parent == null)
                            return new URLClassLoader(array);
                        else
                            return new URLClassLoader(array, parent);
                    }
                });
    }

大块的if中的代码实际上是对资源进行转化加载的过程,而return部分才是返回类加载器的部分,代码根据是否有parent调用了URLClassLoader不同的构造器,Common ClassLoader调用的是没有parent的构造器

图4. Common ClassLoader的parent创建的底层关键代码

按红线所画Common ClassLoader的parent实际上是JDK中sun.misc.Launcher.class类的loader成员变量,而在上一篇文章中已经知道该loader的值就是应用类加载器(系统类加载器)System ClassLoader。至此Tomcat中类加载机制和JDK的类加载机制也建立上了联系
现在Tomcat的类加载机制已完成了一大半,剩下用于加载每个web应用的类加载器WebApp ClassLoader的分析,这个时候需要重新回到代码清单1中看注释(2)以下的部分,其主要做的事情是通过反射创建了org.apache.catalina.startup.Catalina类的实例,然后调用了签名为void setParentClassLoader(ClassLoader parentClassLoader)的方法,并传入了Shared ClassLoader,上面我们说过默认情况下Shared ClassLoader就是Common ClassLoader,因此其传入的参数实际上是Common ClassLoader
我们思考既然有保存parent的方法,必定使用时会调用获得parent方法,那么我们需要查看Catalina类中ClassLoader getParentClassLoader()方法的调用栈(层级关系比较复杂,要紧跟主线不要迷失),最终定位到StandardContext中的synchronized void startInternal() throws LifecycleException方法(其中涉及到Tomcat的各种组件关系,生命周期管理等内容,将在下次分析Tomcat组件文章中详细介绍),下面是只保留核心逻辑的startInternal()方法 代码清单4

    protected synchronized void startInternal() throws LifecycleException {
        // 其他逻辑略......

        // Add missing components as necessary
        if (webappResources == null) {   // (1) Required by Loader
            if (log.isDebugEnabled())
                log.debug("Configuring default Resources");
            try {
                String docBase = getDocBase();
                if (docBase == null) {
                    setResources(new EmptyDirContext());
                } else if (docBase.endsWith(".war")
                        && !(new File(getBasePath())).isDirectory()) {
                    setResources(new WARDirContext());
                } else {
                    setResources(new FileDirContext());
                }
            } catch (IllegalArgumentException e) {
                log.error(sm.getString("standardContext.resourcesInit"), e);
                ok = false;
            }
        }
        if (ok) {
            if (!resourcesStart()) {
                throw new LifecycleException("Error in resourceStart()");
            }
        }

        // (1)  为每一个web应用创建一个WebappLoader
        if (getLoader() == null) {
            WebappLoader webappLoader = new WebappLoader(getParentClassLoader());
            webappLoader.setDelegate(getDelegate());
            setLoader(webappLoader);
        }

      // 其他逻辑略......

        try {

            if (ok) {
                // (2)  调用WebappLoader的start
                // Start our subordinate components, if any
                if ((loader != null) && (loader instanceof Lifecycle))
                    ((Lifecycle) loader).start();
                }

        // 其他逻辑省略......

        } finally {
            // Unbinding thread
            unbindThread(oldCCL);
        }
    }

(1)处注释下的代码逻辑就是为每一个web应用创建一个类加载器,该类加载器的父类加载器就是通过getParentClassLoader()得到的Shared ClassLoader(Common ClassLoader),(2)处代码调用了WebappLoaderstart方法,继续跟进

    protected void startInternal() throws LifecycleException {
        // 其他逻辑省略.....
        try {
            //创建类加载器关键方法
            classLoader = createClassLoader();
            classLoader.setResources(container.getResources());
            classLoader.setDelegate(this.delegate);
            classLoader.setSearchExternalFirst(searchExternalFirst);
            if (container instanceof StandardContext) {
                classLoader.setAntiJARLocking(
                        ((StandardContext) container).getAntiJARLocking());
                classLoader.setClearReferencesRmiTargets(
                        ((StandardContext) container).getClearReferencesRmiTargets());
                classLoader.setClearReferencesStatic(
                        ((StandardContext) container).getClearReferencesStatic());
                classLoader.setClearReferencesStopThreads(
                        ((StandardContext) container).getClearReferencesStopThreads());
                classLoader.setClearReferencesStopTimerThreads(
                        ((StandardContext) container).getClearReferencesStopTimerThreads());
                classLoader.setClearReferencesHttpClientKeepAliveThread(
                        ((StandardContext) container).getClearReferencesHttpClientKeepAliveThread());
            }

        // 其他逻辑省略.....
    }

由于Tomcat的设计,WebappLoaderstart方法实际上调用的是父类的模板,而模板中的startinternal方法由各个子类具体实现,其中最关键的方法为createClassLoader()

图5. WebappLoader中createClassLoader方法

上图中的loadClass成员变量的值为org.apache.catalina.loader.WebappClassLoader,所以,实际上该类为每一个web应用创建了一个WebappClassLoader的实例,该实例的parent就是Shared ClassLoader或者Common ClassLoader,至此WebApp ClassLoader在图1中的位置也得以验证。
从理论上分析来看,由于类加载的“双亲委派”机制,一个类加载器只能加载本加载器指定的目录以及使用有“继承”关系的父类加载器加载过的类,而Tomcat为每一个Web应用创建了一个WebappClassLoader,不同的WebappClassLoader是同级关系,不会存在交叉访问的问题,从而达到web应用相互隔离的目的。
那Tomcat是否没有"破坏"双亲委派机制呢?我们通过查看WebappClassLoader及其父类WebappClassLoaderBaseloadClass()findClass()分析一下Tomcat加载web应用相关类的策略

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

        synchronized (getClassLoadingLockInternal(name)) {
            if (log.isDebugEnabled())
                log.debug("loadClass(" + name + ", " + resolve + ")");
            Class<?> clazz = null;

            // Log access to stopped classloader
            if (!started) {
                try {
                    throw new IllegalStateException();
                } catch (IllegalStateException e) {
                    log.info(sm.getString("webappClassLoader.stopped", name), e);
                }
            }
            //                (1)          
            // Check our previously loaded local class cache
            clazz = findLoadedClass0(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }

            //              (2)
            //  Check our previously loaded class cache
            clazz = findLoadedClass(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }

            //                (3)
            //  Try loading the class with the system class loader, to prevent
            //       the webapp from overriding J2SE classes
            try {
                clazz = j2seClassLoader.loadClass(name);
                if (clazz != null) {
                    if (resolve)
                        resolveClass(clazz);
                    return (clazz);
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }

            //  Permission to access this class when using a SecurityManager
            if (securityManager != null) {
                int i = name.lastIndexOf('.');
                if (i >= 0) {
                    try {
                        securityManager.checkPackageAccess(name.substring(0,i));
                    } catch (SecurityException se) {
                        String error = "Security Violation, attempt to use " +
                            "Restricted Class: " + name;
                        if (name.endsWith("BeanInfo")) {
                            // BZ 57906: suppress logging for calls from
                            // java.beans.Introspector.findExplicitBeanInfo()
                            log.debug(error, se);
                        } else {
                            log.info(error, se);
                        }
                        throw new ClassNotFoundException(error, se);
                    }
                }
            }

            //              (4)
            boolean delegateLoad = delegate || filter(name);

            //              (5)
            //  Delegate to our parent if requested
            if (delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader1 " + parent);
                try {
                    clazz = Class.forName(name, false, parent);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    // Ignore
                }
            }

            //            (6)
            //  Search local repositories
            if (log.isDebugEnabled())
                log.debug("  Searching local repositories");
            try {
                clazz = findClass(name);
                if (clazz != null) {
                    if (log.isDebugEnabled())
                        log.debug("  Loading class from local repository");
                    if (resolve)
                        resolveClass(clazz);
                    return (clazz);
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }

            // Delegate to parent unconditionally
            if (!delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader at end: " + parent);
                try {
                    clazz = Class.forName(name, false, parent);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    // Ignore
                }
            }
        }

        throw new ClassNotFoundException(name);
    }

我们首先定位到WebappClassLoaderBaseloadClass方法,(1)处首先看name对应的类是否存在缓存中,缓存是一个ConcurrentHashMap<String, ResourceEntry>的集合,如果没有缓存执行(2)处逻辑,从JVM中查找是否曾今加载过该类,(3)中的代码保证自定义类不会覆盖java基础类库中的类,(4)的逻辑就是是否进行双亲委派的分叉口,其中delegate默认为false,那么就要看filter(String)方法,该方法的内部实际上将待加载类的全路径名称和一个成员变量protected static final String[] packageTriggers中的类名进行比较,如果待加载的类名和packageTriggers数组中的内容前缀匹配,则需要委派父类加载,即执行(5)处代码,否则执行(6),调用重写的findClass(String)方法加载该类

public Class<?> findClass(String name) throws ClassNotFoundException {
        // 其他代码略去.....
       
        // Ask our superclass to locate this class, if possible
        // (throws ClassNotFoundException if it is not found)
        Class<?> clazz = null;
        try {
            if (log.isTraceEnabled())
                log.trace("      findClassInternal(" + name + ")");
            //        (1)
            if (hasExternalRepositories && searchExternalFirst) {
                try {
                    clazz = super.findClass(name);
                } catch(ClassNotFoundException cnfe) {
                    // Ignore - will search internal repositories next
                } catch(AccessControlException ace) {
                    log.warn("WebappClassLoaderBase.findClassInternal(" + name
                            + ") security exception: " + ace.getMessage(), ace);
                    throw new ClassNotFoundException(name, ace);
                } catch (RuntimeException e) {
                    if (log.isTraceEnabled())
                        log.trace("      -->RuntimeException Rethrown", e);
                    throw e;
                }
            }
            //            (2)
            if ((clazz == null)) {
                try {
                    clazz = findClassInternal(name);
                } catch(ClassNotFoundException cnfe) {
                    if (!hasExternalRepositories || searchExternalFirst) {
                        throw cnfe;
                    }
                } catch(AccessControlException ace) {
                    log.warn("WebappClassLoaderBase.findClassInternal(" + name
                            + ") security exception: " + ace.getMessage(), ace);
                    throw new ClassNotFoundException(name, ace);
                } catch (RuntimeException e) {
                    if (log.isTraceEnabled())
                        log.trace("      -->RuntimeException Rethrown", e);
                    throw e;
                }
            }
            
      //其他代码略去........
        return (clazz);

    }

(1)处由于hasExternalRepositoriessearchExternalFirst默认为false,因此执行(2)处逻辑,调用findClassInternal(String)方法

图6. WebappClassLoader类的findClassInternal方法

其主要的思想是根据待加载类的全路径读取该类的二进制数据,进而进行类的预定义、class source的解析等,将该类加载到JVM中
综上所述,我认为Tomcat的类加载机制不能算完全“正统”的双亲委派,WebappClassLoader内部重写了loadClassfindClass方法,实现了绕过“双亲委派”直接加载web应用内部的资源,当然可以通过在Context.xml文件中加上<Loader delegate = "true">开启正统的“双亲委派”加载机制

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

推荐阅读更多精彩内容