高并发秒杀API(四)

前言

本篇将完成WEB层的设计与开发,包括:

  • Spring MVC与Spring、MyBatis整合
  • 设计并实现Restful接口

一、Spring MVC与Spring整合

之前Spring与MyBatis已经进行过整合了,当通过DispatcherServlet加载Spring MVC的时候,DispatcherServlet同时会把Spring相关的配置也会整合到Spring MVC中,这样就实现了三个框架的整合,即MyBatis+Spring+Spring MVC

打开web.xml,在Eclipse中位置是src/main/webapp/WEB-INF

  <!-- 配置DispatcherServlet -->
  <servlet>
    <servlet-name>seckill-dispatcher</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    
    <!-- 配置Spring MVC需要加载的配置文件 -->
    <init-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath:spring/spring-*.xml</param-value>
    </init-param>
  </servlet>

首先配置的是Spring MVC中央控制器的Servlet,即DispatcherServlet,所有Spring MVC的请求都由DispatcherServlet来分发

然后配置Spring MVC需要加载的配置文件,所有在spring目录下的xml配置文件都要加载进来,之前完成的配置文件有spring-dao.xml和spring-service.xml

  <servlet-mapping>
    <servlet-name>seckill-dispatcher</servlet-name>
    <!-- 默认匹配所有请求 -->
    <url-pattern>/</url-pattern>
  </servlet-mapping>

接着是servlet-mapping,默认匹配所有请求,也就是所有请求都会被DispatcherServlet拦截

在src\main\resources\spring下新建spring-web.xml

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd">
<beans>

把以上内容复制到spring-web.xml

开始配置Spring MVC

<!-- 开启Spring MVC注解模式 -->
<mvc:annotation-driven/>

开启Spring MVC注解模式,这一步是一个简化配置,提供了以下功能:

  • 自动注册DefaultAnnotationHandlerMapping,也就是默认地URL到Handler的映射是通过注解的方式
  • 自动注册AnnotationMethodHandlerAdapter,这个是基于注解的Handler适配器
  • 数据绑定
  • 数字和日期的format,也就是转换,例如@NumberFormat,@DataTimeFormat
  • 提供xml,json默认读写支持
    总而言之,我们可以通过不同的注解来完成以上的功能,当然这些功能不仅可以使用注解,也可以使用额外的xml配置文件甚至是编程的方式,根据项目的不同采用不同的方式
<!-- 静态资源默认servlet配置 -->
<mvc:default-servlet-handler/>

前面配置了servlet-mapping,映射路径为“/”,使用这样配置的话,就需要这个处理方式,有两个作用:

  • 加入对静态资源的处理,即js、png等
  • 允许使用"/"做整体映射

接着配置jsp

<!-- 配置输出样式为JSP 显示ViewResolver -->
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
    <property name="prefix" value="/WEB-INF/jsp/"/>
    <property name="suffix" value=".jsp"/>
</bean>

也就是需要默认的文档输出是jsp和json,不过json不需要我们提供,因为在开始配置Spring MVC注解模式的时候,已经提供了json的读写支持,只要对应到相应的注解就行

因为可能要用到el表达式或者jstl标签,所以配置一个viewClass

还要配置一个识别JSP文件前缀的属性,设置jsp文件存放在/WEB-INF/jsp目录下,再加上后缀

<!-- 扫描WEB相关的bean -->
<context:component-scan base-package="org.seckill.web"/>

扫描WEB相关的bean

接着按照我粗浅的理解,简单的说一下Spring MVC的运行流程:


Spring MVC运行流程图

1、用户发送的请求,所有的请求都会映射到DispatcherServlet,这是一个中央控制器的Servlet,这个Servlet会拦截所有的请求,对应在项目中应该就是web.xml中配置的servlet-mapping标签
2、DispatcherServlet默认的会使用DefaultAnnotation HandlerMapping,主要的作用就是映射URL,哪个URL对应哪个handler,对应在项目中就是在spring-web.xml中mvc:annotation-driven,即开启Spring MVC的注解模式
3、DispatcherServlet默认的会使用DefaultAnnotation HandlerAdapter,用于做Handler适配,对应在项目中就是在spring-web.xml中mvc:annotation-driven,即开启Spring MVC的注解模式
4、DefaultAnnotation HandlerAdapter最终会衔接这次开发的SeckillController,最终的产生就是ModelAndView
5、ModelAndView会与中央控制器DispatcherServlet进行交互
6、通过第五步的交互,DispatcherServlet会发现应用的是InternalResource ViewResolver,这个其实就是jsp默认的View
7、通过第五步的交互,DispatcherServlet也会把Model和list.jsp相结合,
8、最终返回给用户
实际开发的时候只有蓝色的部分,其他的可以使用默认的注解形式,非常方便地映射URL,去对应到相应的逻辑,同时控制输出数据和对应的页面

二、设计Restful接口

一种软件架构风格,设计风格而不是标准,只是提供了一组设计原则和约束条件。它主要用于客户端和服务器交互类的软件。基于这个风格设计的软件可以更简洁,更有层次,更易于实现缓存等机制。--百度百科

通过这个项目,我对Restful接口的理解是:

这是一种优雅的URL表达方式,通过这种URL表达式可以明显的感知到这个URL代表的是什么业务场景或者什么的数据、资源

以下是本项目的URL设计:

  • /seckill/list:秒杀列表,GET方式
  • /seckill/{id}/detail:详情页,GET方式
  • /seckill/time/now:系统时间,通过系统时间为基准,对秒杀操作进行提前的计时的操作逻辑,GET方式
  • /seckill/{id}/exposer:暴露秒杀,通过这个URL才能拿到最后要执行秒杀操作的URL,POST方式
  • /sekcill/{id}/{md5}/execution:执行秒杀,POST方式

三、使用Spring MVC实现Restful接口

在org.seckill包下新建一个web包,用于存放所有的controller,新建一个SeckillController类

@Controller
@RequestMapping("/seckill")
public class SeckillController

标注这个类是一个Controller,使用@Controller注解,目的是将这个类放入Spring容器当中

还要加上一个@RequestMapping注解,代表的是模块,由于我们使用比较规范的URL设计风格,所有的URL应该是:

/模块/资源/{id}/更加细分

要获取列表页,也就是要调用Service

//实例化日志对象,导入org.slf4j包
private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
@Autowired
private SeckillService seckillService;

将Service注入到当前的Controller下,SeckillService在Spring容器中只有一个,Spring容器根据类型匹配,会直接找到bean的实例,然后注入到当前的Controller下

1、秒杀列表页

@RequestMapping(value = "/list", method = RequestMethod.GET)
public String list(Model model){
        
    //获取列表页
    List<Seckill> list = seckillService.getSeckillList();
    model.addAttribute("list", list);
    return "list";

}

参数model就是用来存放渲染list.jsp的数据

@RequestMapping(value = "/list", method = RequestMethod.GET)

这里Spring MVC的注解映射使用的是@RequestMapping注解,其中value的值是二级URL,后面的method属性限制了http请求的方式,这个方法只接收GET方式的http请求,如果是POST请求,Spring MVC将不会做映射

@RequestMapping注解它支持很多种URL映射:

  • 支持标准的URL
  • 支持Ant风格URL,即?、*、**等字符
    • ?表示匹配一个字符
    • *表示匹配任意字符
    • **表示匹配任意URL路径
  • 带{}占位符的URL

举个栗子:

/user/*/creation可以匹配/user/aaa/creation、/user/bbb/creation等URL
/user/**/creation可以匹配/user/creation、/user/aaa/bbb/creation等URL
/user/{userId}可以匹配user/213、user/abc等URL  123、abc可以以参数的方式传入
/company/{companyId}/user/{userId}/detail匹配/company/123/user/456/detail等URL

在list方法中,通过实例化的SeckillService调用其中的方法

/**
* 查询所有秒杀商品记录
* @return
*/
List<Seckill> getSeckillList();//这是SeckillService接口中的方法
//获取列表页
List<Seckill> list = seckillService.getSeckillList();
model.addAttribute("list", list);
return "list";

model就是用来存放数据的,并把返回的数据通过字符串进行标识,最后返回一个字符串,那么这里为什么返回一个字符串?这个字符串会被怎么处理?

之前介绍的Spring MVC运行流程


Spring MVC运行流程

HandlerAdapter在对Handler,即SeckillController进行处理之后会返回一个ModelAndView对象,在获得了ModelAndView对象之后,Spring就需要把该View渲染给用户,即返回给浏览器。在这个渲染的过程中,发挥作用的就是ViewResolver和View

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">
    <property name="viewClass" value="org.springframework.web.servlet.view.JstlView"/>
    <property name="prefix" value="/WEB-INF/jsp/"/>
    <property name="suffix" value=".jsp"/>
</bean>

在spring-web.xml文件中使用的ViewResolver是InternalResourceViewResolver

InternalResourceViewResolver 会把返回的视图名称都解析为 InternalResourceView 对象, InternalResourceView 会把 Controller 处理器方法返回的模型属性都存放到对应的 request 属性中,然后通过 RequestDispatcher 在服务器端把请求 forword 重定向到目标 URL 。比如在 InternalResourceViewResolver 中定义了 prefix=/WEB-INF/ , suffix=.jsp ,然后请求的 Controller 处理器方法返回的视图名称为 test ,那么这个时候 InternalResourceViewResolver 就会把 test 解析为一个 InternalResourceView 对象,先把返回的模型属性都存放到对应的 HttpServletRequest 属性中,然后利用 RequestDispatcher 在服务器端把请求 forword 到 /WEB-INF/test.jsp

这就是 InternalResourceViewResolver 一个非常重要的特性,我们都知道存放在 /WEB-INF/ 下面的内容是不能直接通过 request 请求的方式请求到的,为了安全性考虑,我们通常会把 jsp 文件放在 WEB-INF 目录下,而 InternalResourceView 在服务器端跳转的方式可以很好的解决这个问题

2、秒杀详情页

@RequestMapping(value = "/{seckillId}/detail", method = RequestMethod.GET)
public String detail(@PathVariable("seckillId") Long seckillId, Model model){
        
    if(seckillId == null){
        return "redirect:/seckill/list";
    }
    Seckill seckill = seckillService.getById(seckillId);
    if(seckill == null){
        return "forward:/seckill/list";
    }
    model.addAttribute("seckill", seckill);
    return "detail";
        
}

之前说过,@RequestMapping注解支持多种URL映射,本项目所设计的URL就有带{}占位符的URL

@RequestMapping(value = "/{seckillId}/detail", method = RequestMethod.GET)
public String detail(@PathVariable("seckillId") Long seckillId, Model model)

通过@PathVariable注解绑定后面的参数seckilId,然后对应到URL占位符,当用户传递对应的URL时,@RequestMapping注解中占位符{seckillId}的值会传入detail方法中对应的参数,因为不同的秒杀商品有不同的详情页,所以在二级URL上使用占位符标识不同id的秒杀商品

接着对传进来的seckillId进行判断

if(seckillId == null){
    return "redirect:/seckill/list";
}
Seckill seckill = seckillService.getById(seckillId);
if(seckill == null){
    return "forward:/seckill/list";
}

先要判断seckillId有没有传进来,如果没有传进来,就请求转发到list页面,会回到列表页;如果传进来的seckillId的值不属于任何秒杀商品,那么仍然会重定向到列表页

这里简单说下请求转发与重定向:

  • 从地址栏显示来说:
    • forward:服务器请求资源,服务器直接访问目标地址的URL,把那个URL的响应内容读取过来,然后把这些内容再发给浏览器,浏览器根本不知道服务器发送的内容从哪里来的,所以它的地址栏还是原来的地址
    • redirect:服务端根据逻辑,发送一个状态码,告诉浏览器重新去请求那个地址,所以地址栏显示的是新的URL,redirect等于客户端向服务器端发出两次request,同时也接受两次response
  • 从数据共享来说
    • forward:转发页面和转发到的页面可以共享request里面的数据
    • redirect:不能共享数据
  • 从运用地方来说
    • forward:一般用于用户登陆的时候,根据角色转发到相应的模块
    • redirect:一般用于用户注销登陆时返回主页面和跳转到其它的网站等
  • 从效率来说
    • forward:高
    • redirect:低

这里使用redirect和forward没有特别的用意

model.addAttribute("seckill", seckill);
return "detail";

接着就是使用model存储数据,并返回一个字符串

3、秒杀地址暴露

@RequestMapping(
        value = "/{seckillId}/exposer", 
        method = RequestMethod.POST,
        produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId){
        
    SeckillResult<Exposer> result;
    try {
        Exposer exposer = seckillService.exportSeckillUrl(seckillId);
        result = new SeckillResult<Exposer>(true, exposer);
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
        result = new SeckillResult<Exposer>(false, e.getMessage());
    }
    return result;
}

同样在方法上使用@RequestMapping注解设置二级URL,限制http请求方式为POST,并且通过produces返回HttpResponse的hanndler,告诉浏览器这是一个application/json,同时设置编码为UTF-8

使用@ResponseBody注解,Spring MVC会把返回的数据封装成json

之前说过有个DTO层,主要是用来封装Service层与WEB层之间的数据,这里在dto包下新建一个SeckillResult类,用于封装数据结果

//封装json结果
public class SeckillResult<T> {
    
    private boolean success;//判断请求是否成功
    
    private T data;//存放数据
    
    private String error;//错误信息
}

这个类是一个泛型类型

public SeckillResult(boolean success, T data) {
    this.success = success;
    this.data = data;
}

public SeckillResult(boolean success, String error) {
    this.success = success;
    this.error = error;
}

通过success来判断请求是否成功,成功的话,返回从数据库中取得的数据,如果请求不成功,返回错误信息

再生成get和set方法

    SeckillResult<Exposer> result;
    try {
        Exposer exposer = seckillService.exportSeckillUrl(seckillId);
        result = new SeckillResult<Exposer>(true, exposer);
    }

实例化一个SeckillResult<Exposer>对象,调用SeckillService的exportSeckillUrl方法

/**
* 秒杀开启时输出秒杀接口地址
* 否则输出系统时间和秒杀时间
* @param seckillId
* @return
*/
Exposer exportSeckillUrl(long seckillId);//SeckillService接口中的方法

可以看到SeckillService的这个方法返回的是Exposer类型,所以实例化SeckillResult对象的时候,泛型中是Exposer类型

public class Exposer {
    
    //是否开启秒杀
    private boolean exposed;
    
    //加密措施
    private String md5;
    
    //id
    private long seckillId;
    
    //系统当前时间(毫秒)
    private long now;
    
    //秒杀开启时间
    private long start;
    
    //秒杀结束时间
    private long end;
}

从之前定义好的Exposer类中的属性就可以看到,如果开启秒杀的话,页面会返回通过MD5加密过的秒杀的地址,如果没有开启秒杀,则返回系统当前时间及秒杀开启与结束时间,用于倒计时

在SeckillController中,如果秒杀开启,通过调用SeckillService中的exportSeckillUrl方法返回Exposer对象,存放的是MD5及seckillId,然后初始化SeckillResult对象,参数为true,成功返回Exposer对象中的数据

如果这期间出现错误,说明没有请求成功,需要把上面两步try/catch一下

catch (Exception e) {
    logger.error(e.getMessage(), e);
    result = new SeckillResult<Exposer>(false, e.getMessage());
}
return result;

因为没请求成功,所以需要输出错误信息,同时说明不在秒杀活动期内,同样初始化SeckillResult对象,返回Exposer类中的信息,在Exposer中定义的有系统当前时间以及秒杀开启、结束时间,所以如果没有请求成功,在页面返回的是倒计时或者是秒杀结束等字样

4、执行秒杀

@RequestMapping(
        value = "/{seckillId}/{md5}/execution",
        method = RequestMethod.POST,
        produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId, 
                                                   @PathVariable("md5") String md5,
                                                   @CookieValue(value = "killPhone", required = false) Long phone){
        
    if(phone == null){
        return new SeckillResult<SeckillExecution>(false, "未注册");
    }
    //SeckillResult<SeckillExecution> result;
    try {
        SeckillExecution execution = seckillService.executeSeckill(seckillId, phone, md5);
        return new SeckillResult<SeckillExecution>(true, execution);
    } catch (RepeatKillException e) {
        SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.REPEAT_KILL);
        return new SeckillResult<SeckillExecution>(true, execution);
    } catch (SeckillCloseException e) {
        SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.END);
        return new SeckillResult<SeckillExecution>(true, execution);
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
        SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.INNER_ERROR);
        return new SeckillResult<SeckillExecution>(true, execution);
    }
}

方法上的注解就不多说了,和上面一样,所有的ajax请求返回的都是统一的SeckillResult,之前在dto包中已经定义了SeckillException,用于封装秒杀执行后的结果

/**
 * 封装秒杀执行后的结果
 * @author Fzero
 *
 */
public class SeckillExecution {
    
    private long seckillId;
    
    //秒杀结果执行后的状态
    private int state;
    
    //状态信息
    private String stateInfo;

    //秒杀成功对象
    private SuccessKilled successKilled;
}

这里就可以理解DTO作为Service与WEB层之间数据传递

因为所有的秒杀都要有用户的标识,本项目没有做登录模块,所以使用手机号phone作为用户的标识,可以看到@RequestMapping注解中的请求参数中没有phone,这个参数是由用户浏览器的Request请求的cookie中获取到的,这里Spring MVC处理cookie有个小问题,如果不设置required属性为false的时候,当请求的header中没有一个cookie叫killPhone的时候,Spring MVC会报错,所以在@CookieValue注解中将required设置为false

if(phone == null){
    return new SeckillResult<SeckillExecution>(false, "未注册");
}
try {
    SeckillExecution execution = seckillService.executeSeckill(seckillId, phone, md5);
    return new SeckillResult<SeckillExecution>(true, execution);
} 

这里先使用if简单的判断一下,实际项目中,要验证的参数很多,可以采用Spring MVC的验证信息,所以这里的killPhone不是必须的,验证用户的逻辑放在代码中

对于执行秒杀操作,可能会出现各种异常和错误,所以这里需要try/catch以下,并且有些特定的异常比如重复秒杀、秒杀结束等,之前单独建立了一个exception包,专门存放与业务相关的异常

/**
* 执行秒杀操作
* @param seckillId
* @param userPhone
* @param md5
* @return
*/
SeckillExecution executeSeckill(long seckillId, long userPhone, String md5)
        throws SeckillException,RepeatKillException,SeckillCloseException;

这是SeckillService中定义的方法,可以看到抛出了不同的异常,所以对于这些特定的异常,要单独的catch

catch (RepeatKillException e) {
      SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.REPEAT_KILL);
      return new SeckillResult<SeckillExecution>(true, execution);
}

这个是处理重复秒杀的异常,重新初始化SeckillExecution对象,向数据字典传入对象异常的标识,结果是返回初始化的SeckillResult,上面说过SeckillExecution对象是用于封装秒杀执行后的结果,这里的参数为true,因为当初在SeckillResult定义布尔类型的success的时候就说明这是判断请求是否成功,这里的重复秒杀显然是请求成功,所以参数为true

catch (SeckillCloseException e) {
      SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.END);
      return new SeckillResult<SeckillExecution>(true, execution);
}

秒杀关闭异常

catch (Exception e) {
      logger.error(e.getMessage(), e);
      SeckillExecution execution = new SeckillExecution(seckillId, SeckillStateEnum.INNER_ERROR);
      return new SeckillResult<SeckillExecution>(true, execution);
}

如果不是上述特定的两个异常,其他的异常都视为inner_error

最后一个方法就是获取系统时间

@RequestMapping(value = "/time/now", method = RequestMethod.GET)
@ResponseBody
public SeckillResult<Long> time(){
    Date now = new Date();
    return new SeckillResult<Long>(true, now.getTime());
}

至此,WEB层完成了

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

推荐阅读更多精彩内容