MyBatis3

传统JDBC的不足

有些项目的sql语句会写在程序当中,修改语句时需要修改代码,违反了OCP原则;
使用PrepareStatement预编译sql语句时,需要逐个对参数赋值,过程比较繁琐;
将结果集封装成对象时,需要逐个对成员变量赋值,过程繁琐。

MyBatis的特点

MyBatis的底层是对JDBC的封装,通常被用于持久层的半自动化ORM框架。
将接口和Java的POJOs映射成数据库中的记录;
支持定制化sql,存储过程,基本映射和高级映射;
避免了JDBC中绝大多数需要手动设置参数以及对结果集进行手动封装;
支持XML开发和注解开发;
体积小,只需要2个jar包和2个xml配置文件;
提供丰富的映射标签,支持编写动态sql。

启动第一个MyBatis程序

在pom.xml文件中引入MyBatis和相对版本的Mysql驱动的依赖;
创建MyBatis的配置文件,文件名和存储位置可以自定义;
创建Mapper的配置文件,文件名和存储位置可以自定义;
通过字节流读取MyBatis的配置文件,创建SqlSessionFactoryBuilder;
通过SqlSessionFactoryBuilder获取SqlSessionFactory,再通过SqlSesssionFactorty获取SqlSession;
最终通过SqlSession执行定义在Mapper配置文件中的sql语句。
需要注意的地方是JDBC默认事务是不提交的,需要手动提交。

在MyBatis程序中使用logback日志框架

在配置文件中配置启用logback日志;
创建logback配置文件。

在MyBatis程序中使用#{}占位符

在Java程序中,将数据放到Map集合中;在sql语句中使用#{Map.key}完成传值。
#{Map.key}等同于JDBC中的?,即#{Map.key}就是占位符。
还可以使用POJO完成传参。sql语句中使用#{POJO.成员变量名}
如果采用Map集合传参的方式,#{}中写的是Map集合的key,如果这个key不存在,程序不会报错,数据库中会插入NULL;
如果采用POJO传参的方式,#{}中写的是POJO对象中getXxx()方法名去掉get之后剩下的单词首字母小写。所以当对应的getXxx()方法不存在时,程序就会报错。
编写Mapper配置文件时,sql标签中有个parameterType属性,显式配置这个属性,可以指定传参的数据类型(Map或者POJO)。这个属性是可以省略的。
当占位符只有一个时,#{}的内容可以是任意字符。

在MyBatis程序中查询数据

对于select语句,需要指定这个sql的结果类型,否则会报错。
当查询结果的字段名和POJO对象的属性名对应不上时,那么对应不上的字段就无法正确地赋值给结果对象对应的属性。
解决这个问题的办法之一是使用as关键字指定别名。

Mapper配置文件中的namespace

namespace属性可以防止sqlid冲突。

Mybatis配置文件的相关标签

configuration:根标签
environments:可以为MyBatis配置多个环境
environment:具体的环境配置,包括数据源配置和事务管理器的设置
transactionManager:事务管理器。可以指定JDBC或者MANAGEDJDBC使用原生的事务管理机制,底层原理是先通过conn.setAutoCommit(false);开启事务,之后进行业务处理,最终通过conn.commit();提交事务。
dataSource:指定数据源。通过type属性指定连接池策略。
mappers:配置多个sql映射文件,也就是Mapper配置文件
mapper:配置映射文件的路径。使用resource属性指定相对于类路径的配置文件的路径;使用url属性则需要完全限定资源定位符(URL)。

通过实现简易的MyBatis框架理解底层逻辑

根据使用MyBatis框架的流程,设计出以下几个部分。
1.SqlSessionFactoryBuilder
通过dom4j解析配置文件中的配置,包括数据源,事务管理器,和所有sql映射对象(xxxMapper接口配置文件)的相关配置。
通过这个构造器创建SqlSessionFactory对象。
2.SqlSessionFactory
通过使用这个SqlSession工厂对象获取Session对象。
工厂对象要包含事务管理器和sql映射对象的集合,在获取Session对象后要把这些数据都交给Session处理。
3.SqlSession
SqlSession对象是具体执行sql语句的对象。在这个类中有执行各种sql语句的具体实现,通过形参中的sqlId和参数对象实现对应的方法。
通过工厂类传递过来的事务管理器获取Connection对象和控制事务。
在执行DML语句的具体实现方法中,需要把定义在配置文件中的sql语句中的#{}占位符通过正则表达式替换成?,并遍历出所有在{}里的成员变量的名称,通过反射调用目标对象的getXxx()方法后,再将得到的参数正确赋给PrepareStatement对象。最终就可以完成sql的执行。
在执行DQL语句的具体实现方法中,同样需要把定义在配置文件中的sql语句中的#{}占位符通过正则表达式替换成?,遍历出所有在{}里的成员变量的名称,通过反射调用目标对象的setXxx()方法后,将sql执行后的结果集中的数据赋给返回值对象的成员变量。最终将返回值对象返回。
4.MappedStatement
sql映射对象实体类,封装了有关sql的所有信息。包括sqlId,返回值类型,参数类型,sql语句,sql语句类型。
通过参数类型可以判断使用Map集合传值或者POJO传值。
通过返回值类型可以反射出需要返回的对象。
所有的sql映射对象实体类在SqlSessionFactoryBuilder中就已经被解析出来,然后被统一放到一个Map集合中,Map的key就是namespace拼接sqlId,value就是MappedStatement对象。
5.实现了javax.sql.DataSource的DataSource类
用来在TransactionManager中获取Connection对象。
这个类也在SqlSessionFactoryBuilder中就已经被解析出所有数据并创建出来,并通过TransactionManager的构造方法传递到事务管理器当中。
6.TransactionManager
提供一个接口,指定不同事务处理的方法,提高拓展性。通过配置文件中指定的事务管理器类型创建不同的实现类。在创建类时,需要显示指定是否自动提交。
在MyBatis框架中,如果使用JDBC管理事务,那么是不会自动提交的。
通过以上6个类,就能实现简易的类似MyBatis框架的核心功能。由此可见,MyBatis框架的设计是相当精巧而强大的。

MyBatis核心对象的作用域

SqlSessionFactoryBuilder

这个类的实例的最佳作用域是方法作用域。当创建好SqlSessionFactory后就不需要它了。所以保证所有的XML解析资源释放给其他更重要的事情

SqlSessionFactory

这个类的实例被创建后应该在程序运行期间一直存在。最佳实践是在应用运行期间不要重复创建,最佳作用域就是应用作用域。

SqlSession

SqlSession实例不是线程安全的,所以每个线程都要有自己的SqlSession实例。最佳作用域是请求或者方法作用域。

SqlSession的事务问题

当SqlSession出现线程安全问题时,此时的事务管理是失效的。需要控制不同业务的SqlSession统一使用一个SqlSession对象。方法是使用ThreadLocal。

通过使用Javassist技术实现MyBatis中动态生成DaoImpl类代码理解底层原理

在代码中指定了Mapper配置文件的namespace属性和Dao接口的全限定名称一致,id属性和Dao接口的方法名称一致。

MyBatis中的几处细节

#{}${}的区别

#{}:先编译sql语句,再给占位符传值。底层实现是PrepareStatement。可以防止sql注入。比较常用。
${}:先进行sql语句拼接,然后再编译。底层实现是Statement。只有在需要进行sql语句关键字拼接的情况下才会用到。

在配置文件中使用typeAlias配置对象类型的别名
//使用<typeAlias>
//alias属性不是必须的,且不区分大小写
<typeAliases>
  <typeAlias type="com.example.exampleClass" alise="eClass" />
</typeAliases>
//使用<package>
//可以使用多个<package>标签
<typeAliases>
  <package name="com.example.pojo" />
</typeAliases>
引入Mapper配置文件的几种方式
// 使用resource属性
<mappers>
  <mapper resource="org/example/mapper/xxxMapper.xml" />
</mappers>
// 使用url属性
<mappers>
  <mapper url="fiIe:///org/example/mapper/xxxMapper.xml" />
</mappers>
// 使用class属性
<mappers>
  <mapper class="org.example.mapper.xxxMapper.xml" />
</mappers>
// 使用<package>标签
<mappers>
  <package name="org.example.mapper" />
</mappers>
实现自增主键

MyBatis提供了insertUseGeneratedKeys()方法

MyBatis对参数的处理

单个简单类型参数
<select id="selectByName" resultType="student" parameterType="java.Iang.String">
  select * from t_student 
    where name = #{name, javaType=String, jdbcType=VARCHAR }
</select>

MyBatis具有强大的自动类型判断,所以不需要特别指定。
resultTypejavaTypeparameterType都是可以省略的。

使用Map集合传递参数
// 准备的Map集合
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("name", "John");
paramMap.put("age", 12);

// 使用占位符接收参数
<select id="selectByNameAndAge" resultType="student">
  select * from t_student
    where name = #{name} and #{age}
</select>

通过#{Map.key}获取参数

使用实体类参数
// 准备实体类参数
Student student = new Student();
student.setName("John");
student.setAge(12);

// sql映射配置
<insert id="insertStudent">
  insert into t_student values(#{name}, #{age})
</insert>

// Mapper接口方法
int insert(Student student)

#{} 里写的是属性名称。本质是set()/get()方法去掉set/get后的名称。

多个参数的情况
// 调用接口
studentMapper.selectByNameAndAge("John", 12);
// sql映射文件
<select id="selectByNameAndAge" resultType="student">
  select * from t_student 
    where name = #{arg0} and #{arg1}
</select>

使用arg0arg1表示第一个,第二个参数,以此类推。
也可以使用param0param1表示。
在MyBatis3.4.2之前的版本中,要使用#{0},#{1}这种形式。
底层原理是创建一个Map集合用来保存参数,使用arg0/param0作为key,传递的参数作为value。

使用注解@Param的情况
// Mapper接口中定义的方法
List<Student> selectByNameAndAge(@Param(value="name") String name, @Param("age") int age);

// sql映射文件
<select id="selectByNameAndAge" resultTyle="student">
  select * from t_student
    where name = #{name} and age = #{age}
</select>

使用注解可以增强可读性。
底层实现原理是,MyBatis会将Mapper接口调用的方法的参数放到一个参数数组中,将注解中的参数名按顺序保存在一个SortMap中。然后按顺序取出参数名作为key,参数作为value保存在一个HashMap中。同时还会将param作为默认前缀拼接从0开始的数字作为key,再次保存一遍参数。

Select语句的返回结果与结果映射

<select id="selectAll" resultType="student" />

当查询的结果有对应的实体类,并且查询结果只有一条时,可以使用一个对象实例接收,也可以使用一个List集合接收;
当查询到多条记录时,只能用集合接收。

<select id="selectAll" resultType="map" />

当返回的数据没有对应的实体类时,可以采用Map集合接收。此时字段名作为key,字段的值作为value;
当查询结果条数大于等于1时,可以返回一个存储Map集合的List集合,但是查询结果条数大于1时,必须使用List集合接收数据。

// Mapper接口
@MapKey("id)
Map<Long, Map<String, Object>> selectAllRetMap();

当使用Map集合接收数据时,可以使用Map<id,Map>这样的嵌套Map集合。此时要使用@MapKey注解在需要指定为key的字段上。

// 使用resultMap进行结果映射
<resultMap id="carResultMap" type="car">
  <id property="id" colum="id" />
  <result property="carNum" colum="car_num" />
  <result property="brand" colum="brand" javaType="string" jdbcType="VARCHAR" />
</resultMap>

// sql
<select id="selectAllWithResultMap" resultMap="carResultMap">
  select * from t_cat
</select>

使用结果映射可以将数据库中的字段名和POJO类的属性名一一对应。使用这个方法时,假如字段名和属性名是可以对应时,可以省略,javaType属性和jdbcType属性也可以省略。

// MyBatis配置文件
<settings>
  <setting name="mapUnderscoreToCamelCase" value="true" />
</settings>

在MyBatis配置文件中开启驼峰命名自动匹配也可以将数据库中的字段名和POJO类的属性名一一对应。前提是属性名遵循Java的命名规范,数据库表的列名遵循sql的命名规范。

// Mapper配置文件
<select id="selectTotal" resultType"long">
  select count(*) from t_car
</select>

使用long关键字查询总记录的条数

动态标签

if标签
<mapper namespace="com.example.mapper.CarMapper">
  <select id="selectByMultiCondition" resultType="car">
    select * from t_car 
      where 1=1
    <if test="brand != null and brand != ' '">
      and brand like #{brand}"%"
    </if>
    <if test="carType != null and carType != ' '">
      and carType = #{carType}
    </if>
  </select>
</mapper>

使用if标签可以控制sql中的条件是否拼接。当条件不成立时不会拼接到sql中。使用恒成立的等式可以防止第一个条件不成立导致的语法问题。

where标签
<mapper namespace="com.example.mapper.CarMapper">
  <select id="selectByMultiCondition" resultType="car">
    select * from t_car 
      <where>
        <if test="brand != null and brand != ' '">
          and brand like #{brand}"%"
        </if>
        <if test="carType != null and carType != ' '">
          and carType = #{carType}
        </if>
  </select>
</mapper>

使用where标签可以保证当所有的条件都不成立时不会生成where子句,同时自动清除条件前面不合理的andor。注意,条件后面的andor是不会被清除的。

trim标签
<mapper namespace="com.example.mapper.CarMapper">
  <select id="selectByMultiCondition" resultType="car">
    select * from t_car 
      <trim prefix="where" suffixOverrides="and|or">
        <if test="brand != null and brand != ' '">
          brand like #{brand}"%" and
        </if>
        <if test="carType != null and carType != ' '">
          carType = #{carType}
        </if>
    </trim>
  </select>
</mapper>

使用trim标签可以更灵活的动态拼接sql语句,又4个属性。
prefix:在trim标签中的语句前添加内容
suffix:在trim标签中的语句后添加内容
prefixOverrides:需要覆盖掉的前缀中的内容
suffixOverrides:需要覆盖掉的后缀中的内容
需要注意的是只有当trim标签存在sql语句时,以上约束才会生效。

set标签
<mapper namespace="com.example.mapper.CarMapper">
  <update id="updateWithSet">
    update t_car
      <set>
      <if test="carNum!=null and carNum!=' '">
        car_num = #{carNum},
      </if>
      <if test="brand!=null and brand!=' '">
        brand = #{brand}
      </if>
    </set>
    where id = #{id}
  </update>
</mapper>

用在update语句中,生成set关键字,同时去掉字段末多余的,

choose when otherwise标签
<mapper namespace="com.example.mapper.CarMapper">
  <select id="selectWithChoose" resultType="car">
    <where>
      <choose>
        <when test="brand!=null and brand!=' '">
          brand like #{brand}"%"
        </when>
        <otherwise>
          produce_time >= #{produceTime}
        </otherwise>
      </when>
  </select>
</mapper>

这三个标签在一起使用,只有一个分支被选择。

foreach标签
<mapper namespace="com.example.mapper.CarMapper">
  <insert id="insertBatchByForeach">
    insert into t_car values
    <foreach collection="cars" item="car" separator=",">
      (null, #{car.carNum}, #{car.brand})
    </foreach>
  </insert>
  <delect id="delectBatchByForeach">
    delect from t_car where id in
      <foreach collection="ids" item="id" separator="," open="(" close=")">
        #{id}
      </foreach>
  </delect>
</mapper>

遍历传来的参数,使用separator分隔,使用openclose包围。

sql标签
<mapper namespace="com.example.mapper.CarMapper">
  <sql id="carCols">id, car_num, brand, car_type</sql>
  <select id="selectAll" result="car">
    select <include refid="carCols" /> from t_car
  </select>
  <select id="selectCarById" result="map">
    select <include refid="carCols" /> from t_car where id=#{id}
  </select>
</mapper>

使用sql标签声明sql片段;
使用include标签将声明过的片段包含到某个sql语句中。

高级映射以及延迟加载

多对一的实现方式
<mapper namespace="com.example.mapper.StudentMapper">
  <resultMap id="studentResultMap" type="Student">
    <id property="sid" colum="sid" />
    <result property="sname" colum="sname" />
    <result property="clazz.cid" colum="cid" />
    <result property="clazz.cname colum="cname" />
  </resultMap>
  <select id="selectById" resultMap="studentResultMap">
    select s.*, c.* from t_student s join t_clazz c
      on s.cid = c.cid where s.sid = #{sid}
  </select>
</mapper>

使用result标签将所有字段进行级联属性映射。

<mapper namespace="com.example.mapper.StudentMapper">
  <resultMap id="studentResultMap" type="Student">
    <id property="sid" colum="id" />
    <result property="sname" colum="sname" />
    <association property="clazz" javaType="clazz>
      <id property="cid" colum="cid" />
      <result property="cname" colum="cname" />
    </association>
  </resultMap>
  <select id="selectById" resultMap="studentResultMap">
    select s.*, c.* from t_student s join t_clazz c
      on s.cid = c.cid where s.sid = #{sid}
  </select>
</mapper>

使用association标签将两个对象进行关联。可读性更好,代码复用性更强。

// Mybatis-config.xml
<settings>
  <setting name="lazyloadingEnabled" value="true" />
</settings>

// StudentMapper.xml
<mapper namespace="com.example.mapper.StudentMapper">
  <resultMap id="studentResultMap" type="Student">
    <id property="sid" colum="sid" />
    <result property="sname" colum="sname" />
    <association property="clazz"
                 select="com.example.mapper.ClazzMapper.selectById"
                 colum="cid"
                 fetchType="lazy"
  </resultMap>
  <select id="selectById" resultMap="studentResultMap">
    select s.*, c.* from t_student s join t_clazz c
      on s.cid = c.cid where s.sid = #{sid}
  </select>
</mapper>

// ClazzMapper.xml
<mapper namespace="com.example.mapper.ClazzMapper">
  <select id="selectById" resultType="Clazz">
    select * from t_clazz where cid = #{cid}
  </select>
</mapper>

association标签中将另一个sql的sqlID写在select属性的位置,就可以实现分步查询。分步查询的优点是可以支持延迟加载,需要在MyBatis配置文件中使用lazyLoadingEnabled启用全局延迟加载。或者在sql映射文件中使用fetchType启用局部延迟加载。

一对多的实现方式
<mapper namespace="com.example.mapper.ClazzMapper">
  <resultMap id="clazzResultMap" type="Clazz">
    <id property="cid" colum="cid" />
    <result property="cname" colum="cname" />
    <collection property="students" ofType="Student">
      <id property="sid" colum="sid"/>
      <result property="sname" colum="sname" />
    </collection>
  </resultMap>
  <select id="selectClazzAndStudentsByCid" resultMap="clazzResultMap">
    select * from t_clazz c join t_student s
      on c.cid = s.cid where c.cid = #{cid}
  </select>
</mapper>

使用collection标签实现一对多映射。

// MyBatis-config.xml
<settings>
  <setting name="lazyLoadingEnabled" value="true">
</settings>

// ClazzMapper.xml
<mapper namespace="com.example.mapper.ClazzMapper">
  <resultMap id="clazzResultMap" type="Clazz">
    <id property="cid" colum="cid" />
    <result property="cname" colum="cname" />
    <collection property="students" /
                select="com.example.mapper.StudentMapper.selectByCId"
                column="cid"
                fetch="lazy" />
  </resultMap>
  <select id="selectClazzAndStudentsByCid" resultMap="clazzResultMap">
    select * from t_clazz c join t_student s
      on c.cid = s.cid where c.cid = #{cid}
  </select>
</mapper>

// StudentMapper.xml
<mapper namespace="com.example.mapper.StudentMapper">
  <select id="selectByCid" result="Student">
    select * from t_student where cid = #{cid}
  </select>
</mapper>

collection标签中将另一个sql的sqlId写在select属性的位置,实现分步查询。一对多的延迟加载和多对一的开启方式相同。

缓存

一级缓存

MyBatis的一级缓存默认开启,只要用一个SqlSession对象执行同一个sql语句就可以使用缓存。
使用clearCache()方法可以手动清空缓存,此时缓存会失效;
两次查询之间出现增删改操作,一级缓存和二级缓存都会失效。

二级缓存

使用二级缓存需要具备以下条件:
1.在配置文件中配置<setting name="cacheEnabled" value="true" />开启缓存,默认是开启。
2.在需要使用二级缓存的sql映射文件中配置<cache />
3.使用二级缓存的实体类必须实现java.io.Serializable接口
4.SqlSession对象关闭或者提交后,一级缓存中的数据才会被写入二级缓存中。此时二级缓存才会生效。

二级缓存的淘汰算法

可以使用eviction属性指定淘汰算法,默认是LRU策略。
LRU:Least Recently Used,最近最少使用策略
FIFO:First In First Out,先进先出策略
SOFT:淘汰软引用指向的对象
WEAK:淘汰弱引用指向的对象

分页插件

PageHelper.startPage(startPage, pageSize);
使用MyBatis框架的PageHelper插件能快速使用分页功能

使用注解开发

只推荐使用@Select@Delete@Update@Insert注解开发简单的sql语句,否则只会使让复杂的sql语句更加混乱不堪。给出一个例子。

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

推荐阅读更多精彩内容