源码剖析@ApiImplicitParam对@RequestParam的required属性的侵入性

问题起源

使用SpringCloud构建项目时,使用Swagger生成相应的接口文档是推荐的选项,Swagger能够提供页面访问,直接在网页上调试后端系统的接口, 非常方便。最近却遇到了一个有点困惑的问题,演示接口示例如下(原有功能接口带有业务实现逻辑,这里简化了接口):

/**
 * @description: 演示类
 * @author: Huang Ying
 **/
@Api(tags = "演示类")
@RestController
@Slf4j
public class DemoController {

    @ApiOperation(value = "测试接口")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", dataType = "Long")
    })
    @RequestMapping(value = "/api/json/demo", method = RequestMethod.GET)
    public String auth(@RequestParam(value = "uid") Long uid) {
        System.out.println(uid);
        return "the uid: " + uid;
    }
}

问题出在接口参数uid的必填性上,@RequestParam注解里require默认为true,要求必填,但@ApiImplicitParam注解里require默认为false,要求非必填,该业务接口在进行功能联调时,uid居然能得到一个null值,按照一般认知习惯@ApiImplicitParam注解的主要作用是生成接口文档,不应该对@RequestParam的属性有侵入性才对,目前反馈的bug,让我怀疑@ApiImplicitParam是不是会侵入@RequestParam的require属性?

框架选型、版本及主要功能

项目搭建

SpringBoot版本:2.1.6.RELEASE
SpringCloud版本:Greenwich.SR3

业务模块

SpringCloud业务模块使用的swagger:

swagger bootstrap ui 1.9.6 增强swagger ui样式
spring4all-swagger 1.9.0.RELEASE 配置化swagger参数,免去代码开发

业务网关

SpringCloud业务网关使用的swagger:

knife4j 2.0.1 增强swagger ui样式(网关用gateway搭建,swagger使用knife4j-spring-boot-starter依赖,可以聚合业务模块的swagger文档)

此次的范围只针对SpringCloud业务模块,暂时不涉及业务网关的Swagger文档。

测试工具

测试工具目前有两个:
swagger doc:使用浏览器进行访问,如下图:

image

postman:手动配置接口参数,示例:

image

案例实战

接口测试1

接口示例如开篇所示,我们先使用如下接口,全部使用默认值,即@ApiImplicitParam的required为false,@RequestParam的required为true:

@ApiOperation(value = "测试接口")
@ApiImplicitParams({
        @ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", dataType = "Long")
})
@RequestMapping(value = "/api/json/demo", method = RequestMethod.GET)
public String auth(@RequestParam(value = "uid") Long uid) {
    System.out.println(uid);
    return "the uid: " + uid;
}

看swagger的结果:

image

看postman的结果:

image

接口测试2

我们修改@ApiImplicitParam的required值为true,@RequestParam不变,重启模块
@ApiImplicitParam(name = "uid", value = "用户ID", paramType = "query", required = true, dataType = "Long")

看swagger的结果:

image

通过调试浏览器可以发现,为空校验是js完成的,js判断为空后,并未发起请求到后端,这样我们可以认为swagger内@ApiImplicitParam的required参数生效了。

接口测试3

在前面我们使用postman测试接口时,发现参数项是空的,我们加上参数,但不写值测试后,结果让人诧异:

image

并且无论@ApiImplicitParam的required值如何修改,结果都是一样的,肯定有一个地方是搞错了,导致我们误判。

后来仔细查阅资料,发现是我们对@RequestParam的required参数理解错了,这个required为true的含义是:接口参数名一定要存在,但参数后面有没有值它管不着。拿刚刚的例子来说:

这两个请求是通过的:
localhost:8080/api/json//demo?uid
localhost:8080/api/json//demo?uid=

只有这种请求是不通过的:
localhost:8080/api/json//demo?

小结论

经过上述三个接口的测试场景,我们至少可以明确3点:

  1. @ApiImplicitParam的required参数不会对@RequestParam的required值造成侵入,它们俩不相关。
  2. @ApiImplicitParam的required参数会影响swagger doc的js逻辑判断,为空校验是在js层面上完成的。
  3. @RequestParam的required参数默认情况下只会校验是否有该参数名,不校验它是否有值。

源码剖析

swagger部分

上一节当中提及swagger读取@ApiImplicitParam注解的required参数,最终会体现在js上,通过浏览器F12的追踪,定位到swaggerbootstrapui.js文件上,这里摘抄部分源码:

# 点击发送按钮时,逐行读取参数信息,并提取required参数
 paramBody.find("tr").each(function () {
    var paramtr=$(this);
    var cked=paramtr.find("td:first").find(":checked").prop("checked");
    var _urlAppendflag=true;
    //that.log(cked)
    if (cked){
        //如果选中,留意此行的required:paramtr.data("required")信息提取
        var trdata={name:paramtr.find("td:eq(2)").find("input").val(),in:paramtr.data("in"),required:paramtr.data("required"),type:paramtr.data("type"),emflag:paramtr.data("emflag"),schemavalue:paramtr.data("schemavalue")};
        //that.log("trdata....")
        //that.log(trdata);
        //获取key
        //var key=paramtr.find("td:eq(1)").find("input").val();
        var key=trdata["name"];
        //获取value
        var value="";
        var reqflag=false;
        // 后面代码省略
    }
})
 

js上判断该属性required是否为true的处理,js源码如下:

//判断是否required
if (trdata.hasOwnProperty("required")){
    var required=trdata["required"];
    if (required){
        if(!reqflag){
            //必须,验证value是否为空
            if(value==null||value==""){
                validateflag=true;
                var des=trdata["name"]
                //validateobj={message:des+"不能为空"};
                validateobj={message:des+i18n.message.debug.fieldNotEmpty};
                return false;
            }
        }
    }

}

SpringCloud业务模块部分

swagger前端js验证通过可以向后台发送请求,或者使用postman向后台系统发送请求时,开始进入后台的一系列过滤器、Servlet处理,东西还不少:

// 实际的业务方法部分
auth:28, DemoController (com.hy.demo.controller)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)

// 请求参数的提取、控制部分
doInvoke:190, InvocableHandlerMethod (org.springframework.web.method.support)
invokeForRequest:138, InvocableHandlerMethod (org.springframework.web.method.support)
invokeAndHandle:104, ServletInvocableHandlerMethod (org.springframework.web.servlet.mvc.method.annotation)
invokeHandlerMethod:892, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handleInternal:797, RequestMappingHandlerAdapter (org.springframework.web.servlet.mvc.method.annotation)
handle:87, AbstractHandlerMethodAdapter (org.springframework.web.servlet.mvc.method)

// 下面是各种基础Web服务组件的过滤器等,暂时不关心
doDispatch:1039, DispatcherServlet (org.springframework.web.servlet)
doService:942, DispatcherServlet (org.springframework.web.servlet)
processRequest:1005, FrameworkServlet (org.springframework.web.servlet)
doGet:897, FrameworkServlet (org.springframework.web.servlet)
service:634, HttpServlet (javax.servlet.http)
service:882, FrameworkServlet (org.springframework.web.servlet)
service:741, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, WsFilter (org.apache.tomcat.websocket.server)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:84, SecurityBasicAuthFilter (com.github.xiaoymin.swaggerbootstrapui.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:53, ProductionSecurityFilter (com.github.xiaoymin.swaggerbootstrapui.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilter:124, WebStatFilter (com.alibaba.druid.support.http)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:88, HttpTraceFilter (org.springframework.boot.actuate.web.trace.servlet)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:99, RequestContextFilter (org.springframework.web.filter)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:92, FormContentFilter (org.springframework.web.filter)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:93, HiddenHttpMethodFilter (org.springframework.web.filter)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
filterAndRecordMetrics:114, WebMvcMetricsFilter (org.springframework.boot.actuate.metrics.web.servlet)
doFilterInternal:104, WebMvcMetricsFilter (org.springframework.boot.actuate.metrics.web.servlet)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
doFilterInternal:200, CharacterEncodingFilter (org.springframework.web.filter)
doFilter:109, OncePerRequestFilter (org.springframework.web.filter)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)
invoke:202, StandardWrapperValve (org.apache.catalina.core)
invoke:96, StandardContextValve (org.apache.catalina.core)
invoke:490, AuthenticatorBase (org.apache.catalina.authenticator)
invoke:139, StandardHostValve (org.apache.catalina.core)
invoke:92, ErrorReportValve (org.apache.catalina.valves)
invoke:74, StandardEngineValve (org.apache.catalina.core)
service:343, CoyoteAdapter (org.apache.catalina.connector)
service:408, Http11Processor (org.apache.coyote.http11)
process:66, AbstractProcessorLight (org.apache.coyote)
process:853, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1587, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:748, Thread (java.lang)

聚集重点在请求参数的读取校验方面,首先看org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver类的resolveArgument方法:

@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

    // 留意此方法调用
    NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
    MethodParameter nestedParameter = parameter.nestedIfOptional();

    Object resolvedName = resolveStringValue(namedValueInfo.name);
    if (resolvedName == null) {
        throw new IllegalArgumentException(
                "Specified name must not resolve to null: [" + namedValueInfo.name + "]");
    }
    // 后面暂时省略
}

getNamedValueInfo方法的实现如下:

/**
 * Obtain the named value for the given method parameter.
 */
private NamedValueInfo getNamedValueInfo(MethodParameter parameter) {
    NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter);
    if (namedValueInfo == null) {
        namedValueInfo = createNamedValueInfo(parameter);
        namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo);
        this.namedValueInfoCache.put(parameter, namedValueInfo);
    }
    return namedValueInfo;
}

进入createNamedValueInfo(parameter)方法时,这部分代码如下:

@Override
protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) {
    RequestParam ann = parameter.getParameterAnnotation(RequestParam.class);
    return (ann != null ? new RequestParamNamedValueInfo(ann) : new RequestParamNamedValueInfo());
}

/**
 * NamedValueInfo的定义
 * Represents the information about a named value, including name, whether it's required and a default value.
 */
protected static class NamedValueInfo {

    private final String name;

    private final boolean required;

    @Nullable
    private final String defaultValue;

    public NamedValueInfo(String name, boolean required, @Nullable String defaultValue) {
        this.name = name;
        this.required = required;
        this.defaultValue = defaultValue;
    }
}

这段代码很关键,这里只读取@RequestParam注解,不会读@ApiImplicitParam注解,所以@ApiImplicitParam注解不会影响@RequestParam的属性,并且无论是从swagger doc过来的请求,还是postman过来的请求,都执行这一段代码,最终读取注解的结果用CurrenctHashMap存储,key的格式是method 'xxx' parameter y,xxx为方法名,y为参数的顺序号,如method 'auth' parameter 0,基本上可以保证唯一性。

阶段性总结

源码阅读到这里,基本上可以验证前面提及的小结论的前2条,引用一下:

  1. @ApiImplicitParam的required参数不会对@RequestParam的required值造成侵入,它们俩不相关。
  2. @ApiImplicitParam的required参数会影响swagger doc的js逻辑判断,为空校验是在js层面上完成的。
  3. @RequestParam的required参数默认情况下只会校验是否有该参数名,不校验它是否有值。

前面2个问题已经从源码中找到解释,来看第3个问题:如果参数设置required=true,但只是要求参数名存在,如果此字段是Long类型或Integer类型,写成uid=或'uid',也能通过校验,最终进入方法后,还是得手动写代码进行为空校验,这显然不是我们想要的结果?该如何解决呢?

请求参数data bind的问题

接上一节,如果这样通用的参数,得挨个判断是否为空,这样的做法就有点难受了,有没有更好的解决办法呢?预期的实现效果是字段加上require=true后,Long类型或其他数值类型可以把"",null过滤掉,要不然require还有什么意义呢?

解决方法有两个思路:

  1. POST请求方法中将多个参数封装到一个POJO类里,用@RequestBody声明,POJO类中可以使用@Validator框架的@NotNull等注解,并在参数前声明@Valid。
  2. 自定义参数绑定规则扩展。

方案2更通用一些,适用GET、POST请求,并且原有的单个参数声明无需封装到POJO类里。

官网本身提供自定义参数绑定的扩展,见https://docs.spring.io/spring/docs/5.1.8.RELEASE/spring-framework-reference/web.html#mvc-ann-initbinder

官网的例子是在指定的Controller类中使用@InitBinder注解,影响范围仅限该Controller类,示例如下:

@InitBinder
public void initBinder(WebDataBinder binder) {
    /*
     * 注册对于String类型参数对象的属性进行trim操作的编辑器,
     * 构造参数代表空串是否转为null,false,则将null转为空串。
     */
    binder.registerCustomEditor(String.class, new StringTrimmerEditor(false));
    // 这里我还添加了其他类型的属性编辑器,true表示允许使用"",并且将""处理为空,false表示不允许使用""
    binder.registerCustomEditor(Short.class, new CustomNumberEditor(Short.class, false));
    binder.registerCustomEditor(Integer.class, new CustomNumberEditor(Integer.class, false));
    binder.registerCustomEditor(Long.class, new CustomNumberEditor(Long.class, false));
    binder.registerCustomEditor(Float.class, new CustomNumberEditor(Float.class, false));
    binder.registerCustomEditor(Double.class, new CustomNumberEditor(Double.class, false));
    binder.registerCustomEditor(BigDecimal.class, new CustomNumberEditor(BigDecimal.class, false));
    binder.registerCustomEditor(BigInteger.class, new CustomNumberEditor(BigInteger.class, false));
}

由于此次面临的问题是全模块@RequestParam的值的问题,需要做一个全局的配置,此时需要新增一个类,并使用@ControllerAdvice注解,代码如下:

@ControllerAdvice
public class CustomWebBindingInitializer implements WebBindingInitializer {

    @InitBinder
    @Override
    public void initBinder(WebDataBinder binder) {
        /*
         * 注册对于String类型参数对象的属性进行trim操作的编辑器,
         * 构造参数代表空串是否转为null,false,则将null转为空串。
         */
        binder.registerCustomEditor(String.class, new StringTrimmerEditor(false));
        // 这里我还添加了其他类型的属性编辑器,true表示允许使用"",并且将""处理为空,false表示不允许使用""
        binder.registerCustomEditor(Short.class, new CustomNumberEditor(Short.class, false));
        binder.registerCustomEditor(Integer.class, new CustomNumberEditor(Integer.class, false));
        binder.registerCustomEditor(Long.class, new CustomNumberEditor(Long.class, false));
        binder.registerCustomEditor(Float.class, new CustomNumberEditor(Float.class, false));
        binder.registerCustomEditor(Double.class, new CustomNumberEditor(Double.class, false));
        binder.registerCustomEditor(BigDecimal.class, new CustomNumberEditor(BigDecimal.class, false));
        binder.registerCustomEditor(BigInteger.class, new CustomNumberEditor(BigInteger.class, false));
    }

}

注意一下CustomNumberEditor实例初始化的传的false参数。

重启应用,看一下效果:

image

扩展DataBinder后相关源码阅读

都已经到这儿了,再加把劲把相关的源码看一下,还是在org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver类的resolveArgument方法的后半段:

@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
        NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
    // 前面省略
    if (binderFactory != null) {
        WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
        try {
            // 在这里对参数进行转换
            arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
        }
        catch (ConversionNotSupportedException ex) {
            throw new MethodArgumentConversionNotSupportedException(arg, ex.getRequiredType(),
                    namedValueInfo.name, parameter, ex.getCause());
        }
        catch (TypeMismatchException ex) {
            throw new MethodArgumentTypeMismatchException(arg, ex.getRequiredType(),
                    namedValueInfo.name, parameter, ex.getCause());

        }
    }

    handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest);

    return arg;
}

binder.convertIfNecessary方法一路跟下去,中间省略一些调用,最终到达org.springframework.beans.propertyeditors.CustomNumberEditor类的setAsText方法:

/**
 * Parse the Number from the given text, using the specified NumberFormat.
 */
@Override
public void setAsText(String text) throws IllegalArgumentException {
    if (this.allowEmpty && !StringUtils.hasText(text)) {
        // Treat empty String as null value.
        setValue(null);
    }
    else if (this.numberFormat != null) {
        // Use given NumberFormat for parsing text.
        setValue(NumberUtils.parseNumber(text, this.numberClass, this.numberFormat));
    }
    else {
        // Use default valueOf methods for parsing text.
        setValue(NumberUtils.parseNumber(text, this.numberClass));
    }
}

仔细看allowEmpty变量,针对Long类型的参数,我们扩展数据绑定时,该变量设置的是false,表示不接受空值,试验中我们传的值是空串,那么这里的条件分支判断就必须对空串转换成数值,执行Long.valueOf("")结果报出运行时异常java.lang.NumberFormatException,告知客户端参数不对,这是期望的结果。

总结

本篇以实际的研发排错过程为出发点,刚开始自己也以为@ApiImplicitParam对@RequestParam的required属性的有侵入性,觉得诧异便深入源码论证自己的想法,经阅读源码后发现事实并不是这样,是刚开始我们对required的理解有误。既然required的作用非常有限,那么肯定能找到通用的解决方案避免手动写代码对所有参数进行为空判断,这些解决一个问题后,发现新的问题,再继续解决,最终得到的结果,分析若有不详尽之处,请指正,谢谢。

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