Spring Session Strategy 详解

HttpSessionStrategy

HttpSessionStrategy 定了三个方法如下:

/**
*从提供的{@link javax.servlet.http.HttpServletRequest}获取请求的会话ID。
* 例如,会话ID可能来自Cookie或请求标头。
*/
String getRequestedSessionId(HttpServletRequest request);

/**
*此方法在创建新会话时调用,并应通知客户端新会话ID是什么。 
*例如,它可能创建一个带有会话ID的新Cookie,或者设置一个带有新会话ID值的HTTP响应头。
*注意这里的 session 是 org.springframework.session.Session
*/
void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response);

/**
 *当会话无效时调用此方法,并应通知客户端会话标识不再有效。 
 *例如,它可能会删除其中包含会话ID的Cookie,或者设置一个带有空值的HTTP响应标头,指示客户端不再提交该会话ID。
*/
void onInvalidateSession(HttpServletRequest request, HttpServletResponse response);

HeaderHttpSessionStrategy

HeaderHttpSessionStrategy 实现了 HttpSessionStrategy 接口。 它主要的功能是在 HTTP 请请求头中设置设置我们的 sessionId,已经从请求头中获取我们的 sessionId 。默人的请求头参数为 x-auth-token,同时提供了 setHeaderName(String headerName)方法一遍个性化设置存储 sessionId 的请求头参数。HeaderHttpSessionStrategy 通知客户端使session 失效的方式值是将 session 在HTTP头中的请求参数设置为空串("");

private String headerName = "x-auth-token";

public String getRequestedSessionId(HttpServletRequest request) {
    return request.getHeader(headerName);
}

public void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response) {
    response.setHeader(headerName, session.getId());
}

public void onInvalidateSession(HttpServletRequest request, HttpServletResponse response) {
    response.setHeader(headerName, "");
}

/**
 * The name of the header to obtain the session id from. Default is "x-auth-token".
 * @param headerName the name of the header to obtain the session id from.
*/
public void setHeaderName(String headerName) {
    Assert.notNull(headerName, "headerName cannot be null");
    this.headerName = headerName;
}

MultiHttpSessionStrategy

在某些业务场景下可能还需要进一步 HttpServletRequest、HttpServletResponse进行处理,自定义包装。 例如,CookieHttpSessionStrategy 自定义了如何完成网址重写,以选择在多个会话处于活动状态的情况下应使用哪个会话。因此定义此MultiHttpSessionStrategy 接口,此接口继承了HttpSessionStrategy接口、RequestResponsePostProcessor接口。其中在上文提到 HttpSessionStrategy 定义了 session 基本处理方法,而RequestResponsePostProcessor 定义 HttpServletRequest、HttpServletResponse 包装方法。

public interface MultiHttpSessionStrategy extends HttpSessionStrategy, RequestResponsePostProcessor {
}

RequestResponsePostProcessor

RequestResponsePostProcessor 提供一下两个方法便于开发人员去个性化定制我们的 HttpServletRequest、HttpServletResponse 对象。

/**包装HttpServletRequest */
HttpServletRequest wrapRequest(HttpServletRequest request, HttpServletResponse response);

/**包装HttpServletResponse */
HttpServletResponse wrapResponse(HttpServletRequest request, HttpServletResponse response);

HttpSessionManager

HttpSessionManager 维护着一组 session Id 别名映射,以支持多个同时会话管理。

/** 从 HttpServletRequest 获取当前回话的 session id 别名*/
String getCurrentSessionAlias(HttpServletRequest request);

/** 从{@link HttpServletRequest}中获取session Alias 到session id的映射,*/
Map<String, String> getSessionIds(HttpServletRequest request);

/** 通过指定的 sessionAlias 加密 url */
String encodeURL(String url, String sessionAlias);

/**
 * 通过 HttpServletRequest 获取一个新的,唯一的 session Alias。
 * <code>
 * String newAlias = httpSessionManager.getNewSessionAlias(request);
 * String addAccountUrl = httpSessionManager.encodeURL("./", newAlias);
* </code>
*/
String getNewSessionAlias(HttpServletRequest request);

CookieHttpSessionStrategy

提供通过 cookie 的方式来传递 sessionId。cookie 参数名默认为 SESSION。CookieHttpSessionStrategy 实现了MultiHttpSessionStrategy 接口以及HttpSessionManager 接口。

当一个 session 创建后, HTTP 的响应头中将会以指定 cookie名称来存储 sessionID,此cookie 将会被标识为会话cookie,同时以 context path 作为该 cookie 的 path,并被标识了HTTPOnly。此外如果 javax.servlet.http.HttpServletRequest#isSecure()返回true,则cookie将被标记为Secure。如下:

HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Path=/context-root; Secure; HttpOnly

客户端在访问的时候必须带上此cookie:

GET /messages/ HTTP/1.1
Host: example.com
Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6

当 session 过期以后,服务端将会给客户端返回一个过期的 cookie,如下:

HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly

此外 CookieHttpSessionStrategy 默认支持多个同时会话。 一旦与浏览器建立会话,可以通过为{@link #setSessionAliasParamName(String)}指定唯一值来启动另一个会话。 请求如下:

GET /messages/?_s=1416195761178 HTTP/1.1
Host: example.com
Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6

服务端返回的结果为:

HTTP/1.1 200 OK
Set-Cookie: SESSION="0 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 1416195761178 8a929cde-2218-4557-8d4e-82a79a37876d"; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly

要使用原始会话,您可以创建一个没有HTTP参数的请求。 要使用新会话,可以使用HTTP参数_s = 1416195761178的请求。默认情况将会重写urls以包含当前使用的session。

CookieHttpSessionStrategy 源码解析

public final class CookieHttpSessionStrategy implements MultiHttpSessionStrategy, HttpSessionManager {
    private static final String SESSION_IDS_WRITTEN_ATTR = CookieHttpSessionStrategy.class.getName().concat(".SESSIONS_WRITTEN_ATTR");

    static final String DEFAULT_ALIAS = "0";

    static final String DEFAULT_SESSION_ALIAS_PARAM_NAME = "_s";
   
    // session alias 的正则表达式
    private Pattern ALIAS_PATTERN = Pattern.compile("^[\\w-]{1,50}$");

    // 用于存储 session 信息的 cookie 名,可以地址定义。
    private String cookieName = "SESSION";
    
    // session alias 的参数名,可以自定义
    private String sessionParam = DEFAULT_SESSION_ALIAS_PARAM_NAME;

    private boolean isServlet3Plus = isServlet3();

    /** 
     * 获取当前回话的 session id. 
     * 1.首先获取当前客户端所有的session alias 到 session id 的映射 sessionIds (Map).
     * 2.获取当前回话的 session alias 
     * 3.使用获取到的 session alias 从  sessionIds 中获取相应的 session id
     */
    public String getRequestedSessionId(HttpServletRequest request) {
        Map<String,String> sessionIds = getSessionIds(request);
        String sessionAlias = getCurrentSessionAlias(request);
        
        //通过sessionAlias 获取 session id
        return sessionIds.get(sessionAlias);
    }

    /** 
     * 获取当前 session 的 alias。
     * 当前 sessionParam 不存在时返回默认的 DEFAULT_ALIAS (0) 。 sessionParam 用于在 http 请求中保存当前 session 的 alias 的参数名,默认为 "_s".
     * 从当前的 request 获取 sessionParam 的值,如果为空返回默认的 DEFAULT_ALIAS (0) ,否则使用声明 ALIAS_PATTERN 进行正则匹配,验证参数值是否合法。
     * 合法返回值,不能合法返回 DEFAULT_ALIAS。
     * 
     */
    public String getCurrentSessionAlias(HttpServletRequest request) {
        if(sessionParam == null) {
            return DEFAULT_ALIAS;
        }
        String u = request.getParameter(sessionParam);
        if(u == null) {
            return DEFAULT_ALIAS;
        }
        if(!ALIAS_PATTERN.matcher(u).matches()) {
            return DEFAULT_ALIAS;
        }
        return u;
    }

    
    /**
     * 给当前客户端请求获取新的 session alias。
     */
    public String getNewSessionAlias(HttpServletRequest request) {
        // 获取当前请求客户端所有的 session alias 到 session ids 的映射中所有的 alias,如果为空返回 DEFAULT_ALIAS
        Set<String> sessionAliases = getSessionIds(request).keySet();
        if(sessionAliases.isEmpty()) {
            return DEFAULT_ALIAS;
        }
        
        /*
         * 遍历所有的 alias,选取值最大的 alias。这个最大的 alias 同时也代表了最后的创建的 session 的 alias。
         * 最大的 alias + 1 变得到了新的 alias,同时将 long 转化为 16 进制字符串
         */
        
        long lastAlias = Long.decode(DEFAULT_ALIAS);
        for(String alias : sessionAliases) {
            long selectedAlias = safeParse(alias);
            if(selectedAlias > lastAlias) {
                lastAlias = selectedAlias;
            }
        }
        return Long.toHexString(lastAlias + 1);
    }

    /**
     * 将16进制字符串转化为 long 型。
     * @param hex
     * @return
     */
    private long safeParse(String hex) {
        try {
            return Long.decode("0x" + hex);
        } catch(NumberFormatException notNumber) {
            return 0;
        }
    }

    /**
     * 向客户端重写新的session cookie 信息。
     * 
     */
    public void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response) {
        // 获取客户端当前已经存在 session id 集合,如果已经包含指定的 session id 直接返回。
        Set<String> sessionIdsWritten = getSessionIdsWritten(request);
        if(sessionIdsWritten.contains(session.getId())) {
            return;
        }
        
        /*
         * 将session id 加入 sessionIdsWritten 集合中,获取请求中的 session alias 和 session id 的映射信息
         * 获取当前 session 的 session alias ,并将其添加到 session alias 和 session id 的映射 map 中。
         * 构建新的 cookie 信息,写入 response.
         * 
         * 从这里看出,调用此方法可以创建一个新的 session 或者改变当前请求的 session 。因为 getCurrentSessionAlias(request) 获取的当前的 session alias。
         */
        
        sessionIdsWritten.add(session.getId());

        Map<String,String> sessionIds = getSessionIds(request);
        String sessionAlias = getCurrentSessionAlias(request);
        sessionIds.put(sessionAlias, session.getId());
        Cookie sessionCookie = createSessionCookie(request, sessionIds);
        response.addCookie(sessionCookie);
    }

    @SuppressWarnings("unchecked")
    private Set<String> getSessionIdsWritten(HttpServletRequest request) {
        
        /*
         * 这里要特别注意,改方法不仅仅是获取 request 中 SESSION_IDS_WRITTEN_ATTR 属性值 。
         * 当 SESSION_IDS_WRITTEN_ATTR 不存在事,会主动设置一个新的 Set 到 request 中,
         * 因此在上一个 onNewSession() 中便将参数中传递的 session id 已保存在里面。所以,在
         * 我个人看来,此方法名应该改为 getSessionIdsWrittenOrNew() 之类的。
         */
        Set<String> sessionsWritten = (Set<String>) request.getAttribute(SESSION_IDS_WRITTEN_ATTR);
        if(sessionsWritten == null) {
            sessionsWritten = new HashSet<String>();
            request.setAttribute(SESSION_IDS_WRITTEN_ATTR, sessionsWritten);
        }
        return sessionsWritten;
    }

    /**
     * 根据指定的 session ids 给指定的请求创建 session cookie. 特别注意,当sessionIds为空时,相当于使当前 session 失效
     * @param request
     * @param sessionIds
     * @return
     */
    private Cookie createSessionCookie(HttpServletRequest request,
            Map<String, String> sessionIds) {
        Cookie sessionCookie = new Cookie(cookieName,"");
        if(isServlet3Plus) {
            sessionCookie.setHttpOnly(true);
        }
        sessionCookie.setSecure(request.isSecure());
        sessionCookie.setPath(cookiePath(request));
        // TODO set domain?

        // 使 session  失效
        if(sessionIds.isEmpty()) {
            sessionCookie.setMaxAge(0);
            return sessionCookie;
        }
        
        // 当客户端只有一个请求回话时,我们的 session cookie 里面并没有维护 session alias
        if(sessionIds.size() == 1) {
            String cookieValue = sessionIds.values().iterator().next();
            sessionCookie.setValue(cookieValue);
            return sessionCookie;
        }
        
        /*
         * 当客户端维护多个请求回话时
         * 我们的 session cookie 中 session id 和 session alias 的组合模式如下:SESSION="alias1 sessionId1 alias2 sessionId2 ......";
         * 例如:SESSION="0 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 1416195761178 8a929cde-2218-4557-8d4e-82a79a37876d";
         */
        
        StringBuffer buffer = new StringBuffer();
        for(Map.Entry<String,String> entry : sessionIds.entrySet()) {
            String alias = entry.getKey();
            String id = entry.getValue();

            buffer.append(alias);
            buffer.append(" ");
            buffer.append(id);
            buffer.append(" ");
        }
        buffer.deleteCharAt(buffer.length()-1);

        sessionCookie.setValue(buffer.toString());
        return sessionCookie;
    }

     /**
      * 当会话无效时调用此方法,并应通知客户端会话标识不再有效。
      * 从 sessionId 中删除当前回话,并重写 cookie。
      **/
    public void onInvalidateSession(HttpServletRequest request, HttpServletResponse response) {
        Map<String,String> sessionIds = getSessionIds(request);
        String requestedAlias = getCurrentSessionAlias(request);
        
        // 这里移除了当前 session alias 和 session id的映射,并重写了cookie,但是没有移除 request 中 SESSION_IDS_WRITTEN_ATTR 属性中的 session id
        sessionIds.remove(requestedAlias);

        Cookie sessionCookie = createSessionCookie(request, sessionIds);
        response.addCookie(sessionCookie);
    }

    /**
     * Sets the name of the HTTP parameter that is used to specify the session
     * alias. If the value is null, then only a single session is supported per
     * browser.
     *
     * @param sessionAliasParamName
     *            the name of the HTTP parameter used to specify the session
     *            alias. If null, then ony a single session is supported per
     *            browser.
     */
    public void setSessionAliasParamName(String sessionAliasParamName) {
        this.sessionParam = sessionAliasParamName;
    }

    /**
     * Sets the name of the cookie to be used
     * @param cookieName the name of the cookie to be used
     */
    public void setCookieName(String cookieName) {
        if(cookieName == null) {
            throw new IllegalArgumentException("cookieName cannot be null");
        }
        this.cookieName = cookieName;
    }

    /**
     * Retrieve the first cookie with the given name. Note that multiple
     * cookies can have the same name but different paths or domains.
     * @param request current servlet request
     * @param name cookie name
     * @return the first cookie with the given name, or {@code null} if none is found
     */
    private static Cookie getCookie(HttpServletRequest request, String name) {
        if(request == null) {
            throw new IllegalArgumentException("request cannot be null");
        }
        Cookie cookies[] = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (name.equals(cookie.getName())) {
                    return cookie;
                }
            }
        }
        return null;
    }

    private static String cookiePath(HttpServletRequest request) {
        return request.getContextPath() + "/";
    }

    /** 
     * 获取当前请求客户端所有的 session alias 到 session ids 的映射。session alias 和 sessoin ids 保存在名为“SESSION”的 cookie 中, 如下:
     * <pre>
     *  HTTP/1.1 200 OK
     *  Set-Cookie: SESSION="0 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 1416195761178 8a929cde-2218-4557-8d4e-82a79a37876d"; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly
     * </pre>
     */
    public Map<String,String> getSessionIds(HttpServletRequest request) {
        // 获取 session cookie 字符串
        Cookie session = getCookie(request, cookieName);
        // 如果不存在 session 返回空串,如果存在获取session cookie 的值。
        String sessionCookieValue = session == null ? "" : session.getValue();
        // 申明一个 map 集合用于保存 session alias 到 session id 的映射
        Map<String,String> result = new LinkedHashMap<String,String>();
        
        /*
         * StringTokenizer 字符串分隔解析类型, 用于根据指定的模式分割字符串。
         * session cookie 的值中的分割符为“空格字符串”,因此这里使用它解析。
         */
        StringTokenizer tokens = new StringTokenizer(sessionCookieValue, " ");
        
        /*
         * 如果分割后的值只有一个,那么说明当前客户端只开启一个回话,tokens里面的唯一的元素便是当前的 session id。
         * 当只有一个会话是,使用 DEFAULT_ALIAS(0)作为当前 session ids 的别名。
         */
        if(tokens.countTokens() == 1) {
            result.put(DEFAULT_ALIAS, tokens.nextToken());
            return result;
        }
        
        /*
         * 当tokens 大于1时,说明当前客户端开启了多个回话,此时 tokens 的个数一点是偶数。
         * session cookie 中的session id 和 session alias 的组合模式如下:SESSION="alias1 sessionId1 alias2 sessionId2 ......";
         * 例如:SESSION="0 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 1416195761178 8a929cde-2218-4557-8d4e-82a79a37876d";
         */
        while(tokens.hasMoreTokens()) {
            String alias = tokens.nextToken();
            if(!tokens.hasMoreTokens()) {
                break;
            }
            String id = tokens.nextToken();
            result.put(alias, id);
        }
        return result;
    }

    /**
     * 在 request 中设置 session 管理策略
     */
    public HttpServletRequest wrapRequest(HttpServletRequest request, HttpServletResponse response) {
        request.setAttribute(HttpSessionManager.class.getName(), this);
        return request;
    }

    /**
     * 封装 HttpServletResponse 
     */
    public HttpServletResponse wrapResponse(HttpServletRequest request, HttpServletResponse response) {
        return new MultiSessionHttpServletResponse(response, request);
    }

    class MultiSessionHttpServletResponse extends HttpServletResponseWrapper {
        private final HttpServletRequest request;

        public MultiSessionHttpServletResponse(HttpServletResponse response, HttpServletRequest request) {
            super(response);
            this.request = request;
        }

        @Override
        public String encodeRedirectURL(String url) {
            url = super.encodeRedirectURL(url);
            return CookieHttpSessionStrategy.this.encodeURL(url, getCurrentSessionAlias(request));
        }

        @Override
        public String encodeURL(String url) {
            url = super.encodeURL(url);

            String alias = getCurrentSessionAlias(request);
            return CookieHttpSessionStrategy.this.encodeURL(url, alias);
        }
    }

    public String encodeURL(String url, String sessionAlias) {
        String encodedSessionAlias = urlEncode(sessionAlias);
        int queryStart = url.indexOf("?");
        boolean isDefaultAlias = DEFAULT_ALIAS.equals(encodedSessionAlias);
        if(queryStart < 0) {
            return isDefaultAlias ? url : url + "?" + sessionParam + "=" + encodedSessionAlias;
        }
        String path = url.substring(0, queryStart);
        String query = url.substring(queryStart + 1, url.length());
        
        // 这里主要的是为了替换查询参数里面的 sessionParim 的值。
        String replacement = isDefaultAlias ? "" : "$1"+encodedSessionAlias;
        query = query.replaceFirst( "((^|&)" + sessionParam + "=)([^&]+)?", replacement);
        
        /*
         * 需要这一步是为了防止上在上述的 query.replaceFirst( "((^|&)" + sessionParam + "=)([^&]+)?", replacement)
         * 中没有找到  sessionParam 对应的请求参数,这需要在 query 后面拼接 sessionParam。
         * 怎么造成这种情景的呢,大概是客户端开启一个新的 session。
         */
        if(!isDefaultAlias && url.endsWith(query)) {
            // no existing alias
            if(!(query.endsWith("&") || query.length() == 0)) {
                query += "&";
            }
            query += sessionParam + "=" + encodedSessionAlias;
        }

        return path + "?" + query;
    }

    private String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * Returns true if the Servlet 3 APIs are detected.
     * @return
     */
    private boolean isServlet3() {
        try {
            ServletRequest.class.getMethod("startAsync");
            return true;
        } catch(NoSuchMethodException e) {}
        return false;
    }
    
    public static void main(String[] args) {
        String query = "&_s=ygkhldjg&123=jkljkj";
        query = query.replaceFirst( "((^|&)_s=)([^&]+)?", "$1ranqi");
        System.out.println(query);
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容