Spring MVC请求处理(六) - UrlPathHelper类

UrlPathHelper类是Spring中的一个帮助类,有很多与URL路径有关的实用方法,在介绍该类之前先明确一些路径和编码的概念。

Servlet 3.1规范中的路径

Servlet中有三个路径容易混淆,分别是ContextPath、ServletPath和PathInfo。

  • Context Path定义为servlet所属ServletContext关联的路径前缀。若该上下文是处于服务器基地址的默认上下文,则这个路径是空串,这种情况请参考Tomcat的Context配置文档Naming一节。否则,如果这个上下文不处在服务器的根,则这个路径会以/开头但不会以/结尾。
  • Servlet Path定义为请求路径中直接对应到映射的部分。这个路径以/开头,但当请求与/*或空串两种模式匹配时是空串。
  • Path Info定义为请求路径中既不是Context Path也不是Servlet Path的部分,它要么是null,这是没有额外路径的情况下,要么是以/开头的字符串。

在请求路径中,以下等式永远成立:requestURI = contextPath + servletPath + pathInfo。

URL路径

在收到客户端的请求后,由Web容器来决定向哪个Web应用转发该请求。所选择的Web应用一定有最长的ContextPath与请求URL从起始处开始相匹配。URL中匹配的部分就是映射到servlet时的ContextPath。
Web容器必须使用如下的路径映射规则定位处理请求的servlet。
映射到servlet时使用的路径是请求对象中的请求URL除去ContextPath和路径参数(路径参数可以参考这篇文章)。下面的URL路径映射规则需要按顺序使用,第一个匹配后便不再尝试其他匹配:

  1. 容器尝试查找请求路径与servlet的精确匹配;
  2. 容器会递归地尝试匹配最长路径前缀。以/为分隔符,在路径树中一次步进一个目录。最长的匹配会决定由哪个servlet处理;
  3. 如果URL路径中的最后一段包含扩展名(如.jsp),那么容器会尝试匹配能处理扩展名的servlet。扩展名定义为最后一段中最后的点号(.)之后的部分;
  4. 如果前三个规则没有成功匹配,容器会尝试去为所请求的资源提供服务。如果为应用定义了默认servlet,则它会被使用。许多容器都提供了隐式的默认servlet。

映射规范

Web应用部署描述符使用如下规则定义映射:

  1. 以/开头并以/*结尾的字符串用于路径映射;
  2. 以"*."前缀开头的字符串用于扩展名映射;
  3. 空串""是特殊的模式,精确地映射到应用上下文的根,举例来说,对来自http://host:port/<context-root>/的请求,PathInfo是/,ServletPath和ContextPath都是空串"";
  4. 只包含/的字符串表明是应用的默认servlet,这种情况下ServletPath是请求URI减去ContextPath,PathInfo是null;
  5. 其他字符串只会精确匹配。

接下来以一个简单的Spring工程和Tomcat 8.5为例说明典型情况下各路径的值。

实例1

将web.xml中名为dispatcher的servlet映射改为/*,根据映射规范第一条,它可用于路径映射。

<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/*</url-pattern>
</servlet-mapping>

将工程打包为spring-mvc.war,以Get方法访问http://localhost:8080/spring-mvc/paths,日志输出:

contextPath:/spring-mvc
servletPath:""
pathInfo:/paths

解释:

  • 根据URL路径一节,/spring-mvc的Web应用能与/spring-mvc/paths最长匹配,匹配的部分/spring-mvc即是ContextPath;
  • 根据URL路径一节,映射到servlet时使用的路径是/spring-mvc/paths减去/spring-mvc,所以/paths会用于映射匹配,根据URL路径第二条匹配规则,/paths与/*匹配,根据“Servlet Path”一节,ServletPath为"";
  • 根据等式规则可得PathInfo为/paths。

实例2

将web.xml中名为dispatcher的servlet映射改为/,根据映射规范第四条,它是默认的servlet。

<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/</url-pattern>
</servlet-mapping>

将工程打包为spring-mvc.war,以Get方法访问http://localhost:8080/spring-mvc/paths,日志输出:

contextPath:/spring-mvc
servletPath:/paths
pathInfo:null
  • 根据URL路径一节,/spring-mvc的Web应用能与/spring-mvc/paths最长匹配,匹配的部分/spring-mvc即是ContextPath;
  • 根据URL路径一节,映射到servlet时使用的路径是/spring-mvc/paths减去/spring-mvc,所以/paths会用于映射匹配,根据URL路径第四条匹配规则,/paths与由默认servlet处理,根据映射规范第四条,ServletPath为/paths,PathInfo为null。

URI中的编码

  • GET方法:RFC2396第二章指出URI只能包含部分ASCII字符,除了这些字符都需要用百分号转义,但没有规定转义字符时使用何种编码。

  • POST方法:Servlet 3.1规范3.11节指出如果客户端请求没有指定编码,那么默认使用ISO-8859-1解码POST数据。

    Currently, many browsers do not send a char encoding qualifier with the Content-Type header, leaving open the determination of the character encoding for reading HTTP requests. The default encoding of a request the container uses to create the request reader and parse POST data must be “ISO-8859-1” if none has been specified by the client request. However, in order to indicate to the developer, in this case, the failure of the client to send a character encoding, the container returns null from the getCharacterEncoding method.

    ServletRequest接口的setCharacterEncoding方法用来设置编码,该方法可以覆盖容器的默认编码,但必须在POST数据被解析或数据读取之前调用,一旦数据被读取该方法调用便不再有效。

    If the client hasn’t set character encoding and the request data is encoded with a different encoding than the default as described above, breakage can occur. To remedy this situation, a new method setCharacterEncoding(String enc) has been added to the ServletRequest interface. Developers can override the character encoding supplied by the container by calling this method. It must be called prior to parsing any post data or reading any input from the request. Calling this method once data has been read will not affect the encoding.

以Tomcat为例,它使用ISO-8859-1作为URI和查询字符串的默认编码,有两种方法可以指定解析URI和查询字符串的编码:

  • 在server.xml配置文件的<Connector>元素上配置属性URIEncoding,如URIEncoding="UTF-8"。请注意从Tomcat 8开始,URIEncoding属性的默认值是UTF-8;
  • 在server.xml配置文件的<Connector>元素上配置属性useBodyEncodingForURI为true(默认为false),Tomcat会使用Content-Type头或ServletRequest接口的setCharacterEncoding方法指定的编码解析查询字符串,若编码不被支持,那么使用默认的ISO-8859-1。请注意该属性只适用于查询字符串,不适用于URI的路径部分。

UrlPathHelper类

UrlPathHelper类是Spring中的一个帮助类,有很多与URL路径有关的实用方法,现逐一介绍如下。

移除分号

与移除分号有关的方法如下:

public String removeSemicolonContent(String requestUri) {
    return (this.removeSemicolonContent ?
            removeSemicolonContentInternal(requestUri) : removeJsessionid(requestUri));
}

private String removeSemicolonContentInternal(String requestUri) {
    int semicolonIndex = requestUri.indexOf(';');
    while (semicolonIndex != -1) {
        int slashIndex = requestUri.indexOf('/', semicolonIndex);
        String start = requestUri.substring(0, semicolonIndex);
        requestUri = (slashIndex != -1) ? start + requestUri.substring(slashIndex) : start;
        semicolonIndex = requestUri.indexOf(';', semicolonIndex);
    }
    return requestUri;
}

private String removeJsessionid(String requestUri) {
    int startIndex = requestUri.toLowerCase().indexOf(";jsessionid=");
    if (startIndex != -1) {
        int endIndex = requestUri.indexOf(';', startIndex + 12);
        String start = requestUri.substring(0, startIndex);
        requestUri = (endIndex != -1) ? start + requestUri.substring(endIndex) : start;
    }
    return requestUri;
}
  • removeSemicolonContent方法根据removeSemicolonContent属性决定是移除请求URI中的所有分号内容还是只移除jsessionid部分,默认是前者,所以这两种情况都会移除jsessionid部分;
  • removeSemicolonContentInternal方法移除请求URI中所有的分号内容,注意URI中每段都可以有分号,如/users/name;v=1.1/gender;value=male等形式;
  • removeJsessionid方法只移除请求URI中;jsessionid=xxx的部分而保留URI的其余部分(包括其他分号),移除jsessionid时不区分大小写。

URI解码

若设置了解码属性则decodeRequestString方法对URI解码,相关方法代码如下:

public String decodeRequestString(HttpServletRequest request, String source) {
    if (this.urlDecode && source != null) {
        return decodeInternal(request, source);
    }
    return source;
}

@SuppressWarnings("deprecation")
private String decodeInternal(HttpServletRequest request, String source) {
    String enc = determineEncoding(request);
    try {
        return UriUtils.decode(source, enc);
    }
    catch (UnsupportedEncodingException ex) {
        if (logger.isWarnEnabled()) {
            logger.warn("Could not decode request string [" + source + "] with encoding '" + enc +
                    "': falling back to platform default encoding; exception message: " + ex.getMessage());
        }
        return URLDecoder.decode(source);
    }
}

protected String determineEncoding(HttpServletRequest request) {
    String enc = request.getCharacterEncoding();
    if (enc == null) {
        enc = getDefaultEncoding();
    }
    return enc;
}
  • determineEncoding方法调用HttpServletRequest的getCharacterEncoding方法获取编码,若没有则使用默认的ISO-8859-1编码;
  • decodeInternal方法使用上一步得到的编码解码URI,若不支持此编码则使用系统属性file.encoding指定的编码(从URLDecoder.decode的源码可得,注意该编码也是Charset类的defaultCharset方法的返回值)。

清理斜线

getSanitizedPath方法清理斜线,将URI中连续两个斜线替换为一个斜线,其代码如下所示:

private String getSanitizedPath(final String path) {
    String sanitized = path;
    while (true) {
        int index = sanitized.indexOf("//");
        if (index < 0) {
            break;
        }
        else {
            sanitized = sanitized.substring(0, index) + sanitized.substring(index + 1);
        }
    }
    return sanitized;
}

解码并清理

decodeAndCleanUriString方法解码并清理URI,移除分号内容、清理斜线并解码

private String decodeAndCleanUriString(HttpServletRequest request, String uri) {
    uri = removeSemicolonContent(uri);
    uri = decodeRequestString(request, uri);
    uri = getSanitizedPath(uri);
    return uri;
}

getRequestUri方法

HttpServletRequest的getRequestURI方法的返回值未被容器解码且没有去掉分号部分且没有查询字符串,而UrlPathHelper类的getRequestUri方法会对该URI解码、移除分号内容并清理斜线:

public String getRequestUri(HttpServletRequest request) {
    String uri = (String) request.getAttribute(WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE);
    if (uri == null) {
        uri = request.getRequestURI();
    }
    return decodeAndCleanUriString(request, uri);
}

getContextPath方法

HttpServletRequest的getContextPath方法的返回值未被容器解码且去掉了分号部分,而UrlPathHelper类的getContextPath方法会对ContextPath解码:

public String getContextPath(HttpServletRequest request) {
    String contextPath = (String) request.getAttribute(WebUtils.INCLUDE_CONTEXT_PATH_ATTRIBUTE);
    if (contextPath == null) {
        contextPath = request.getContextPath();
    }
    if ("/".equals(contextPath)) {
        // Invalid case, but happens for includes on Jetty: silently adapt it.
        contextPath = "";
    }
    return decodeRequestString(request, contextPath);
}

getServletPath方法

HttpServletRequest的getServletPath方法的返回值已被容器解码且去掉了分号部分,所以UrlPathHelper类的getServletPath方法不再对其解码:

public String getServletPath(HttpServletRequest request) {
    String servletPath = (String) request.getAttribute(WebUtils.INCLUDE_SERVLET_PATH_ATTRIBUTE);
    if (servletPath == null) {
        servletPath = request.getServletPath();
    }
    if (servletPath.length() > 1 && servletPath.endsWith("/") && shouldRemoveTrailingServletPathSlash(request)) {
        // On WebSphere, in non-compliant mode, for a "/foo/" case that would be "/foo"
        // on all other servlet containers: removing trailing slash, proceeding with
        // that remaining slash as final lookup path...
        servletPath = servletPath.substring(0, servletPath.length() - 1);
    }
    return servletPath;
}

实例验证

为了加深对这几个方法的理解,我们接着使用上文使用的项目调试,将war包文件名改为spring mvc.war,接着修改web.xml中的servlet映射,将DispatcherServlet映射到/patt"ern/*

<servlet-mapping>
    <servlet-name>dispatcher</servlet-name>
    <url-pattern>/patt"ern/*</url-pattern>
</servlet-mapping>

发起Get请求:localhost:8080/spring%20mvc/patt%22ern;foo=bar/urlPathHelper;v=1.1//%E6%B5%8B%E8%AF%95?param1=val1,部分日志输出如下:

DEBUG c.s.controller.ServletInfoController - HttpServletRequest#getRequestURI: /spring%20mvc/patt%22ern;foo=bar/urlPathHelper;v=1.1/%E6%B5%8B%E8%AF%95
DEBUG c.s.controller.ServletInfoController - UrlPathHelper#getRequestUri: /spring mvc/patt"ern/urlPathHelper/测试
DEBUG c.s.controller.ServletInfoController - HttpServletRequest#getContextPath: /spring%20mvc
DEBUG c.s.controller.ServletInfoController - UrlPathHelper#getContextPath: /spring mvc
DEBUG c.s.controller.ServletInfoController - HttpServletRequest#getServletPath: /patt"ern
DEBUG c.s.controller.ServletInfoController - UrlPathHelper#getServletPath: /patt"ern

这印证了上文的观点:

  • HttpServletRequest的getRequestURI方法的返回值未被容器解码且没有去掉分号部分,只是去掉了查询字符串,而UrlPathHelper类的getRequestUri方法会对该URI用UTF-8编码方式解码、移除分号内容并清理斜线(%20是空格,%22是双引号,测试两字的UTF-8编码是\xe6\xb5\x8b\xe8\xaf\x95)。
  • 为什么Spring会用UTF-8解码呢,这是因为在web.xml中添加了CharacterEncodingFilter过滤器,为HttpServletRequest使用setCharacterEncoding方法设置了UTF-8编码,否则会使用默认的ISO-8859-1。如果不加过滤器的话则可以在Content-Type头添加charset=UTF-8,这是因为Tomcat在实现ServletRequest接口时getCharacterEncoding方法内部在setCharacterEncoding没有设值时会从Content-Type取得编码,不知道其他容器是否有这种类似的实现;
  • HttpServletRequest的getContextPath方法的返回值未被容器解码且去掉了分号部分,而UrlPathHelper类的getContextPath方法会对ContextPath解码;
  • HttpServletRequest的getServletPath方法的返回值已被容器解码且去掉了分号部分,UrlPathHelper类的getServletPath方法不再对其解码。Tomcat解码时采用哪种编码方式呢?这是由Tomcat的URIEncoding属性指定的,见上文编码一章。

应用中的路径

getPathWithinApplication方法返回请求URI在web应用中的路径,返回的路径已被解码、移除分号内容并清理斜线。

public String getPathWithinApplication(HttpServletRequest request) {
    String contextPath = getContextPath(request);
    String requestUri = getRequestUri(request);
    String path = getRemainingPath(requestUri, contextPath, true);
    if (path != null) {
        // Normal case: URI contains context path.
        return (StringUtils.hasText(path) ? path : "/");
    }
    else {
        return requestUri;
    }
}

/**
 * Match the given "mapping" to the start of the "requestUri" and if there
 * is a match return the extra part. This method is needed because the
 * context path and the servlet path returned by the HttpServletRequest are
 * stripped of semicolon content unlike the requesUri.
 */
private String getRemainingPath(String requestUri, String mapping, boolean ignoreCase) {
    int index1 = 0;
    int index2 = 0;
    for (; (index1 < requestUri.length()) && (index2 < mapping.length()); index1++, index2++) {
        char c1 = requestUri.charAt(index1);
        char c2 = mapping.charAt(index2);
        if (c1 == ';') {
            index1 = requestUri.indexOf('/', index1);
            if (index1 == -1) {
                return null;
            }
            c1 = requestUri.charAt(index1);
        }
        if (c1 == c2 || (ignoreCase && (Character.toLowerCase(c1) == Character.toLowerCase(c2)))) {
            continue;
        }
        return null;
    }
    if (index2 != mapping.length()) {
        return null;
    }
    else if (index1 == requestUri.length()) {
        return ""; // mapping与requestUri全匹配,额外的部分当然是空串了
    }
    else if (requestUri.charAt(index1) == ';') {
        index1 = requestUri.indexOf('/', index1);
    }
    return (index1 != -1 ? requestUri.substring(index1) : "");
}
  • getRemainingPath方法将mapping字符串与requestUri字符串相匹配,匹配过程中忽略掉requestUri中的分号部分。如果能匹配则返回requestUri除去匹配之外的额外部分,否则返回null。举个例子,requestUri是/data;v=1.1/users;foo=bar/extra,mapping是/data/users,那么该方法返回的就是/extra;
  • 正常情况下,getPathWithinApplication方法将匹配之外的额外部分作为请求URI在web应用中的路径;
  • 异常情况下,无匹配返回请求URI。

Servlet映射中的路径

getPathWithinServletMapping方法返回请求URI在Servlet映射中的路径,这里需要再次注意请求URI、ContextPath和ServletPath的编解码和分号,见上文。

/**
 * Return the path within the servlet mapping for the given request,
 * i.e. the part of the request's URL beyond the part that called the servlet,
 * or "" if the whole URL has been used to identify the servlet.
 * <p>Detects include request URL if called within a RequestDispatcher include.
 * <p>E.g.: servlet mapping = "/*"; request URI = "/test/a" -> "/test/a".
 * <p>E.g.: servlet mapping = "/"; request URI = "/test/a" -> "/test/a".
 * <p>E.g.: servlet mapping = "/test/*"; request URI = "/test/a" -> "/a".
 * <p>E.g.: servlet mapping = "/test"; request URI = "/test" -> "".
 * <p>E.g.: servlet mapping = "/*.test"; request URI = "/a.test" -> "".
 * @param request current HTTP request
 * @return the path within the servlet mapping, or ""
 */
public String getPathWithinServletMapping(HttpServletRequest request) {
    String pathWithinApp = getPathWithinApplication(request);
    String servletPath = getServletPath(request);
    String sanitizedPathWithinApp = getSanitizedPath(pathWithinApp); // 貌似这步有些多余,因为getPathWithinApplication方法已经对请求URI在web应用中的路径做了解码、移除分号内容并清理斜线操作
    String path;

    // If the app container sanitized the servletPath, check against the sanitized version
    if (servletPath.contains(sanitizedPathWithinApp)) {
        path = getRemainingPath(sanitizedPathWithinApp, servletPath, false);
    }
    else {
        path = getRemainingPath(pathWithinApp, servletPath, false);
    }

    if (path != null) {
        // Normal case: URI contains servlet path.
        return path;
    }
    else {
        // Special case: URI is different from servlet path.
        String pathInfo = request.getPathInfo();
        if (pathInfo != null) {
            // Use path info if available. Indicates index page within a servlet mapping?
            // e.g. with index page: URI="/", servletPath="/index.html"
            return pathInfo;
        }
        if (!this.urlDecode) {
            // No path info... (not mapped by prefix, nor by extension, nor "/*")
            // For the default servlet mapping (i.e. "/"), urlDecode=false can
            // cause issues since getServletPath() returns a decoded path.
            // If decoding pathWithinApp yields a match just use pathWithinApp.
            path = getRemainingPath(decodeInternal(request, pathWithinApp), servletPath, false);
            if (path != null) {
                return pathWithinApp;
            }
        }
        // Otherwise, use the full servlet path.
        return servletPath;
    }
}
  • getPathWithinServletMapping方法与getPathWithinApplication方法相似,也利用了getRemainingPath方法;
  • 正常情况下,ServletPath在URI中有匹配,getPathWithinServletMapping方法返回的是应用中的路径除去ServletPath;
  • 异常情况没太看懂,好像是处理Javadoc最后一个例子的情况,但/*.test不是一个合法的映射啊……。

请求查找路径

getLookupPathForRequest方法返回请求的查找路径,其代码如下所示:

public String getLookupPathForRequest(HttpServletRequest request) {
    // Always use full path within current servlet context?
    if (this.alwaysUseFullPath) {
        return getPathWithinApplication(request);
    }
    // Else, use path within current servlet mapping if applicable
    String rest = getPathWithinServletMapping(request);
    if (!"".equals(rest)) {
        return rest;
    }
    else {
        return getPathWithinApplication(request);
    }
}

根据alwaysUseFullPath属性(默认是true)做不同的操作:

  • 当设置为true时返回请求在应用中的路径;
  • 当设置为false时,如果没使用整个URL定位servlet,那么返回请求在servlet映射中的路径,否则返回请求在应用中的路径。

该方法用在AbstractHandlerMethodMapping的getHandlerInternal方法中获得查找路径用于寻找匹配的HandlerMethod。

参考文献

Servlet 3.1规范
https://tomcat.apache.org/tomcat-8.5-doc/config/http.html
https://wiki.apache.org/tomcat/FAQ/CharacterEncoding

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

推荐阅读更多精彩内容