Mybatis 插件原理解析

一、构建 SqlSessionFactory 的过程

SqlSessionFactoryMyBatis 核心类之一,其重要功能是创建 MyBatis 的核心接口 SqlSession。MyBatis 通过 SqlSessionFactoryBuilder 构建 SqlSessionFactory,构建分为两步:

  1. 通过 org.apache.ibatis.builder.xml.XMLConfigBuilder 解析 XML 配置文件,读取配置参数并存入 org.apache.ibatis.session.Configuration
  2. 使用 Configuration 构建 SqlSessionFactory,MyBatis 提供了 SqlSessionFactory 的默认实现类 org.apache.ibatis.session.defaults.DefaultSqlSessionFactory

这种创建方式是一种 Builder 模式。对于复杂对象而言,直接使用构造函数构建会导致大量的逻辑放在构造函数中,使得代码看起来很繁杂。使用一个参数类总领全局,然后按步骤构建可以降低构建的复杂性

1.1 构建 Configuration

Configuration 的作用如下:

  1. 读入配置文件,包括基础配置文件和映射器文件
  2. 初始化基础配置,比如 MyBatis 的别名,一些重要的类对象(插件、映射器、ObjectFactoryTypeHandler
  3. 提供 Configuration 单例,为后续创建 SqlSessionFactory 服务提供配置参数

1.2 映射器内部组成

插件需要频繁访问映射器内部组成,所以有必要单独研究一下映射器的内部组成。一般而言,映射器由 3 部分组成:

  1. MappedStatement:保存映射器的一个节点,包括配置的 sql、sql id、resultMap、resultType 等重要配置内容
  2. SqlSource:提供 BoundSql 的地方,是 MappedStatement 的一个属性
  3. BoundSql:建立 SQL 和相关参数的地方,有三个常用属性,sql、parameterObject 和 parameterMappings
image

一般而言,我们主要对参数和 SQL 进行修改,这部分主要反映在 BoundSql 类上,在插件中可以通过 BoundSql 拿到当前运行 SQL 和参数及参数规则,并做出适当修改,满足我们的需求

BoundSql 主要有三个属性:sqlrameterObjectparameterMappings

  1. parameterObject:参数对象本身,可以传递简单对象、POJOMap 或者 @Param 注解的参数,其传递规则如下:
    • 传递简单对象会将其变为简单对象的包装类传递,如 int 变为 Integer 传递
    • 传递 POJOMap,parameterObject 就是传入的 POJOMap 不变
    • 当传递多个参数,如果没有 @Param 注解,那么 MyBatis 会将 parameterObject 变为一个 Map<String, Object> 对象,类似于这样的形式:{"param1":p1, "param2":p2},所以我们可以使用 #{param1} 引用一个参数
    • 如果使用 @Param 注解,那么 MyBatis 会将 parameterObject 变为一个 Map<String, Object> 对象。只是将 Map 的键值置换为 @Param 注解的键值。例如 @Param("key1") String p1, @Param("key2") String p2,parameterObject 的形式为 {"key1":p1, "key2":p2}
  2. parameterMappings:是一个 List,每个元素都是 ParameterMapping 对象。这个对象会描述我们的参数,参数包括属性、名称、表达式、JavaType、typeHandler 信息,一般不会去改变它。通过 parameterMappings 可以实现参数和 SQL 的结合,以便 PreParedStatement 通过 parameterMappings 找到 parameterObject 对象的属性并设置参数
  3. sql:即我们书写在映射器里面的一条 SQL,可以通过插件进行改写

1.3 构建 SqlSessionFactory

有了 Configuration 便可以快速构建 SqlSessionFactory

二、SqlSession 运行过程

2.1 映射器动态代理

todo

2.2 SqlSession 四大对象

映射器其实就是一个动态代理对象,最终会进入 MapperMethodexecute 方法。execute 经过简单判断就调用 SqlSession 的删除、更新、插入、选择等方法。这些方法是通过 ExecutorStatementHandlerParameterHandlerResultHandler 来完成数据库操作和结果返回的

  1. Executor:执行器,负责调度 StatementHandlerParameterHandlerResultHandler 执行 SQL并返回结果
  2. StatementHandler:使用数据库的 Statement(PrepareStatement)执行操作,是四大对象的核心
  3. ParameterHandler:用于 SQL 对参数的处理
  4. ResultHandler:进行最后结果数据集(ResultSet)的封装返回处理

2.2.1 Executor

Executor 负责执行 Java 和 数据库交互,在 MyBatis 中存在三种执行器,可以在配置文件的 setting 元素的 defaultExecutorType 指定

public enum ExecutorType {
  SIMPLE, REUSE, BATCH
}
  1. SIMPLE:简单执行器,它是默认执行器
  2. REUSE:重用预处理语句的执行器
  3. BATCH:重用语句和批量更新,针对批量专用的执行器

构造过程如下:

public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    executorType = executorType == null ? ExecutorType.SIMPLE : executorType;
    Executor executor;
    if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
    } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
    } else {
        executor = new SimpleExecutor(this, transaction);
    }
    if (cacheEnabled) {
        executor = new CachingExecutor(executor);
    }
    executor = (Executor) interceptorChain.pluginAll(executor);
    return executor;
}

执行过程中,interceptorChain.pluginAll(executor) 就是 MyBatis 插件执行的地方,这行代码会为 executor 构建一层层代理对象,在调度真实的 Executor 方法之前执行配置插件的代码可以修改。SimpleExecutor 的查询处理过程如下所示:

public class SimpleExecutor extends BaseExecutor {

@Override
public <E> List<E> doQuery(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
    Statement stmt = null;
    try {
        Configuration configuration = ms.getConfiguration();
        StatementHandler handler = configuration.newStatementHandler(wrapper, ms, parameter, rowBounds, resultHandler, boundSql);
        stmt = prepareStatement(handler, ms.getStatementLog());
        return handler.query(stmt, resultHandler);
    } finally {
        closeStatement(stmt);
    }
}

private Statement prepareStatement(StatementHandler handler, Log statementLog) throws SQLException {
    Statement stmt;
    Connection connection = getConnection(statementLog);
    stmt = handler.prepare(connection, transaction.getTimeout());
    handler.parameterize(stmt);
    return stmt;
}

如代码所示,SimpleExecutor 的查询过程一共有 4 步:

  1. 通过 configuration 创建 StatementHandler
  2. 调用 StatementHandler#prepare 进行预编译和基础设置
  3. 调用 StatementHandler#parameterize 设置参数
  4. 调用 StatementHandler#query 执行查询并通过 ResultHandler 组装查询结果并返回

2.2.2 StatementHandler

StatementHandler,顾名思义就是处理数据库会话的,其创建过程如下:

public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
    StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject, rowBounds, resultHandler, boundSql);
    statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);
    return statementHandler;
}

实际创建的是 RoutingStatementHandler 对象,它实现了 StatementHandler 接口。和 Executor 一样用代理对象做了一层层封装

statementHandler = (StatementHandler) interceptorChain.pluginAll(statementHandler);

RoutingStatementHandler 对象也不是实际处理查询任务的对象,它通过 适配模式 找到对应的 StatementHandler 来执行具体的数据库操作,MyBatis 也有三种 StatementHandler:SimpleStatementHandlerPreparedStatementHandler 以及 CallableStatementHandler

image

我们以最常用的 PreparedStatementHandler 为例讲解 StatementHandler 的三个主要方法:prepare、parameterize 和 query 是如何执行的

首先 Executor 调用 StatementHandler#prepare 进行 SQL 预编译

// BaseStatementHandler
@Override
public Statement prepare(Connection connection, Integer transactionTimeout) throws SQLException {
    ErrorContext.instance().sql(boundSql.getSql());
    Statement statement = instantiateStatement(connection);
    setStatementTimeout(statement, transactionTimeout);
    setFetchSize(statement);
    return statement;
}

instantiateStatement 对 SQL 进行预编译,做了一些基础设置(超时、获取最大行数等),然后 Executor 调用 StatementHandler#parameterize 设置参数

// PreparedStatementHandler 
@Override
public void parameterize(Statement statement) throws SQLException {
  parameterHandler.setParameters((PreparedStatement) statement);
}

可以看到这步操作是调用 ParameterHandler 完成的,2.2.3 中将详细描述参数设置过程。最后 Executor 调用 StatementHandler#query 执行查询

// PreparedStatementHandler 
@Override
public <E> List<E> query(Statement statement, ResultHandler resultHandler) throws SQLException {
  PreparedStatement ps = (PreparedStatement) statement;
  ps.execute();
  return resultSetHandler.handleResultSets(ps);
}

StatementHandler#query 执行 PreparedStatement#execute 执行 SQL,并将结果通过 ResultSetHandler 处理返回,详见 2.2.4

综上所述,我们就知道 MyBatis 处理一条 SQL 的执行过程了,整体过程如下图所示(以 SimpleExecutor 的查询为例):

image

下面我们再具体看看 ParameterHandlerResultHandler 是如何设置参数并将结果封装返回的

2.2.3 ParameterHandler

ParameterHandler 是参数处理器,主要完成对预编译参数的设置。我们先看一下其接口定义:

public interface ParameterHandler {
    Object getParameterObject();
    void setParameters(PreparedStatement ps) throws SQLException;
}

ParameterHandler#getParameterObject 返回参数对象,ParameterHandler#setParameters 设置预编译 SQL 语句的参数

MyBatis 提供了 默认实现类 DefaultParameterHandler,我们主要看一下 setParameters 方法的实现

public void setParameters(PreparedStatement ps) {
    ErrorContext.instance().activity("setting parameters").object(mappedStatement.getParameterMap().getId());
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    if (parameterMappings != null) {
        for (int i = 0; i < parameterMappings.size(); i++) {
            ParameterMapping parameterMapping = parameterMappings.get(i);
            if (parameterMapping.getMode() != ParameterMode.OUT) {
                Object value;
                String propertyName = parameterMapping.getProperty();
                if (boundSql.hasAdditionalParameter(propertyName)) { // issue #448 ask first for additional params
                    value = boundSql.getAdditionalParameter(propertyName);
                } else if (parameterObject == null) {
                    value = null;
                } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                    value = parameterObject;
                } else {
                    MetaObject metaObject = configuration.newMetaObject(parameterObject);
                    value = metaObject.getValue(propertyName);
                }
                TypeHandler typeHandler = parameterMapping.getTypeHandler();
                JdbcType jdbcType = parameterMapping.getJdbcType();
                if (value == null && jdbcType == null) {
                    jdbcType = configuration.getJdbcTypeForNull();
                }
                try {
                    typeHandler.setParameter(ps, i + 1, value, jdbcType);
                } catch (TypeException | SQLException e) {
                    throw new TypeException("Could not set parameters for mapping: " + parameterMapping + ". Cause: " + e, e);
                }
            }
        }
    }
}

2.2.4 ResultHandler

ResultHandler 负责处理 SQL 执行结果集,接口定义如下:

public interface ResultSetHandler {
    <E> List<E> handleResultSets(Statement stmt) throws SQLException;
    <E> Cursor<E> handleCursorResultSets(Statement stmt) throws SQLException;
    void handleOutputParameters(CallableStatement cs) throws SQLException;
}

其中,handleResultSets 负责封装结果集。MyBatis 提供了实现类 DefaultResultSetHandler,它涉及到使用 JAVASSIST 或 CGLIB 作为延迟加载,然后通过 TypeHandlerObjectFactory 进行组装再返回结果

2.3 SqlSession 运行总结

SqlSession 内部运行图如下所示

image

SqlSession 通过 Executor 创建 StatementHandler 来执行 SQL 请求的。StatementHandler 要经过三个步骤:

  1. prepare 预编译 SQL
  2. parameterize 设置参数
  3. query/update 执行 SQL

其中 parameterize 调用 ParameterHandler 设置参数,参数类型根据类型处理器 TypeHandler 处理。query/update 方法通过 ResultHandler 进行结果封装,如果是 update 语句则返回整数,否则通过 TypeHandler 处理结果类型,然后用 ObjectFactory 提供的规则封装对象,返回给调用者

三、插件

3.1 插件接口

在 MyBatis 中使用插件必须实现 Interceptor 接口

public interface Interceptor {
    Object intercept(Invocation invocation) throws Throwable;
    
    default Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
    
    default void setProperties(Properties properties) {
        // NOP
    }
}
  • intercept:直接覆盖你要拦截对象的原有方法,它是插件的核心方法。通过参数 invocation 可以反射调用原对象的方法
  • plugintarget 是被拦截对象,它的作用是给被拦截对象生成一个代理对象并返回。为了方便生成代理对象,MyBatis 使用 org.apache.ibatis.plugin.Plugin#wrap 方法生成代理对象
  • setProperties:允许在 plugin 元素中配置所需参数,方法在插件初始化的时候就被调用一次,然后将插件对象存入到配置中,以便后续取出

可以看到,MyBatis 使用 模板模式 进行插件开发

3.2 插件初始化

插件初始化在 MyBatis 初始化的时候完成,关键代码如下:

// XMLConfigBuilder.class
private void pluginElement(XNode parent) throws Exception {
    if (parent != null) {
        for (XNode child : parent.getChildren()) {
            String interceptor = child.getStringAttribute("interceptor");
            Properties properties = child.getChildrenAsProperties();
            // resolveClass -> (Class<T>) Resources.classForName(string);
            Interceptor interceptorInstance = (Interceptor) resolveClass(interceptor).getDeclaredConstructor().newInstance();
            interceptorInstance.setProperties(properties);
            configuration.addInterceptor(interceptorInstance);
        }
    }
}

// configuration.class
public void addInterceptor(Interceptor interceptor) {
    interceptorChain.addInterceptor(interceptor);
}

// InterceptorChain.class
public void addInterceptor(Interceptor interceptor) {
    interceptors.add(interceptor);
}

MyBatis 在上下文初始化过程中,就开始读入插件节点和我们配置的参数,然后通过反射生成 Interceptor 实例,并调用 setProperties 方法设置我们配置的参数,最后将 interceptorInstance 保存到配置对象 configuration

所以插件的初始化是 MyBatis 初始化的时候完成的,这样有助于提高性能

3.3 插件的代理和反射设计

插件使用的是 责任链模式,责任链处理的对象是 MyBatis 四大对象中的一个,在 InterceptorChain 中的每个 Interceptor 都有机会处理目标对象

1 在 SqlSession 四大对象初始化时,调用 InterceptorChain#pluginAll 为其嵌套生成代理对象

image
// InterceptorChain.class
private final List<Interceptor> interceptors = new ArrayList<>();
public Object pluginAll(Object target) {
    for (Interceptor interceptor : interceptors) {
        target = interceptor.plugin(target);
    }
    return target;
}

2 InterceptorChain#pluginAll 为目标类生成嵌套代理对象,即有多个拦截器,就为目标类嵌套生成代理类

// Interceptor.class
default Object plugin(Object target) {
    return Plugin.wrap(target, this);
}

// Plugin.class 
public static Object wrap(Object target, Interceptor interceptor) {
    // 获取 Interceptor 上 @Intercepts 注解设置的签名方法
    Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
    Class<?> type = target.getClass();
    // 获取 @Signature 注解 type 属性对应的所有签名方法
    Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
    if (interfaces.length > 0) {
        return Proxy.newProxyInstance(type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap));
    }
    return target;
}
image

3 在调用四大对象的方法时,会代用 InvocationHandler#invoke,由于 Plugin 实现了 InvocationHandler 接口,故实际调用 Plugin#invoke 方法

// Plugin.class 
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
        // 获取 @Intercepts 注解的所有拦截方法
        Set<Method> methods = signatureMap.get(method.getDeclaringClass());
        /*
          如果该方法是 @Intercepts 注解设置的拦截方法,则调用 Interceptor.intercept 进行拦截处理,否则直接反射调用 method.invoke(target, args)
          */
        if (methods != null && methods.contains(method)) {
            return interceptor.intercept(new Invocation(target, method, args));
        }
        return method.invoke(target, args);
    } catch (Exception e) {
        throw ExceptionUtil.unwrapThrowable(e);
    }
}

如果该方法是 @Intercepts 注解设置的拦截方法,则调用 Interceptor.intercept 进行拦截处理,否则直接反射调用 method.invoke(target, args)。因此在 Interceptor#intercept 中可以自定义实际的拦截逻辑

Invocation 封装了目标类 target、要代理的方法 method 以及 method 的参数 args,可通过 Invocation#proceed 完成方法的反射调用。具体代码如下

public class Invocation {
    private final Object target;
    private final Method method;
    private final Object[] args;

    public Invocation(Object target, Method method, Object[] args) {
    this.target = target;
    this.method = method;
    this.args = args;
    }

    public Object proceed() throws InvocationTargetException, IllegalAccessException {
        return method.invoke(target, args);
    }
    
    // ignore get and set
}
image

3.4 常用工具类——MetaObject

MetaObject 可有效读取或设置类属性,其关键的方法有三个:

  1. MetaObject SystemMetaObject#forObject(Object object)

    用于包装对象,获取目标对象 object 的 MetaObject

  2. Object MetaObject#getValue(String name)

    获取对象属性值,支持 OGNL

  3. void MetaObject#setValue(String name, Object value)

    设置对象属性值,支持 OGNL

对象导航图语言(Object Graph Navigation Language),简称 OGNL,是一种表达式语言

3.5 插件开发示例

QueryTrancePlugin 插件用于在 SQL 中追加调用链路和应用名称

/**
 * QueryTrancePlugin
 *
 * @author lzn
 * @version 1.0
 * @date 2020/9/23 10:02
 */
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class QueryTrancePlugin implements Interceptor {

    private static final String SPLIT = "@@@";
    private static final String SQL_POS = "delegate.boundSql.sql";
    private static final String SELECT = "select";
    private static final String APPLICATION_NAME = "applicationName";
    private static final String CLASS_REGEX = "classRegex";

    /**
    * 应用名称
    */
    private String applicationName;

    /**
    * 类方法正则表达式
    */
    private String classRegex;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
        MetaObject metaStatementHandler = SystemMetaObject.forObject(statementHandler);

        // 分离代理对象链(由于目标类可能被多个插件拦截,从而形成多层代理,可循环分理出最原始的目标类)
        while (metaStatementHandler.hasGetter("h")) {
            Object object = metaStatementHandler.getValue("h");
            metaStatementHandler = SystemMetaObject.forObject(object);
        }

        String sql = (String) metaStatementHandler.getValue(SQL_POS);

        if (sql != null && sql.toLowerCase().trim().indexOf(SELECT) == 0) {
            StringBuilder builder = new StringBuilder(sql);
            // 获取调用链路
            StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
            builder.append(SPLIT).append(this.applicationName);
            builder.append(SPLIT).append(WebToolUtils.getLocalIP());
            for (StackTraceElement stackTraceElement : stackTraceElements) {
                if (stackTraceElement.getClassName().matches(this.classRegex)) {
                    builder.append(SPLIT).append(stackTraceElement.getClassName()).append("#").append(stackTraceElement.getMethodName());
                    break;
                }
            }
            metaStatementHandler.setValue(SQL_POS, builder.toString());
        }

        return invocation.proceed();
    }

    @Override
    public void setProperties(Properties properties) {
        this.applicationName = properties.getProperty(APPLICATION_NAME);
        this.classRegex = properties.getProperty(CLASS_REGEX);
    }
}

在应用的配置文件中,插件配置如下

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