Spring Session的下一代会话管理

会话管理一直是企业Java的一部分,以至于它逐渐淡出人们对解决问题的意识,并且在最近的记忆中,该领域没有任何重大创新。

但是,微服务和水平可伸缩的云本机应用程序的现代趋势挑战了过去20年设计和构建会话管理器的假设,并暴露了现代会话管理器设计中的缺陷。

本文将演示最近发布的Spring Session API如何帮助克服传统上由企业Java所采用的当前会话管理方法的一些局限性。 我们将从对当前会话管理器存在的问题进行总结开始,然后深入探讨Spring Session如何解决这些问题中的每一个。 我们将在本文结尾处详细说明Spring Session的工作原理以及如何在项目中使用它。

Spring Session将创新带回了企业Java会话管理空间,使您可以轻松地:

  • 编写水平可扩展的云本机应用程序。
  • 将会话状态的存储卸载到专用的外部会话存储中,例如Redis或Apache Geode,它们以独立于应用程序服务器的方式提供高质量的集群。
  • 用户通过WebSocket发出请求时,请保持HttpSession处于活动状态。
  • 从非Web请求处理代码(例如JMS消息处理代码)访问会话数据。
  • 每个浏览器支持多个会话,从而轻松构建更丰富的最终用户体验。
  • 控制客户端和服务器之间会话ID的交换方式,从而轻松编写Restful API,可以从HTTP标头中提取会话ID,而不必依赖Cookie。

重要的是要了解核心Spring Session项目完全不依赖于Spring框架,因此您甚至可以在不使用Spring框架的项目中使用它。

传统会话管理的问题

Spring Session试图解决传统JavaEE会话管理中的各种问题。 下面的示例说明了每个问题。

构建水平可扩展的云本机应用程序

云原生应用程序体系结构假定将通过在大型虚拟机池上的Linux容器中运行更多应用程序实例来扩展应用程序。 例如,很容易将.war文件部署到Cloud Foundry或Heroku上的Tomcat,然后在几秒钟内扩展到100个应用实例,每个实例具有1GB RAM。 您还可以将云平台配置为根据用户需求自动增加和减少应用程序实例的数量。

许多应用程序服务器将HTTP会话状态存储在运行应用程序代码的同一JVM中,因为这易于实现且速度很快。 当新的应用程序服务器实例加入或离开集群时,HTTP会话将在其余的应用程序服务器实例上重新平衡。 在我们正在运行数百个应用服务器实例并且实例数量可以随时快速增加或减少的弹性云环境中,我们遇到一些问题:

  • 重新平衡HTTP会话可能会成为性能瓶颈。
  • 存储大量会话所需的大堆大小可能导致垃圾回收暂停,从而对性能产生负面影响。
  • 云基础结构通常禁止TCP多播,但是会话管理器经常使用TCP多播来发现哪些应用服务器实例已加入或离开集群。

因此,将HTTP会话状态存储在运行应用程序代码的JVM外部的数据存储中更为有效。 例如,可以将100个Tomcat实例配置为使用Redis来存储会话状态,并且随着Tomcat实例数量的增加或减少,Redis中的会话不会受到影响。 另外,由于Redis是用C编写的,因此它可以使用数百GB的RAM甚至可能是TB的RAM,因为没有垃圾收集器会妨碍您。

对于像Tomcat这样的开源服务器,很容易找到使用外部数据存储(例如Redis或Memcached)的会话管理器的替代实现。 但是,配置过程可能很复杂,并且特定于每个应用程序服务器。 对于诸如WebSphere和Weblogic之类的封闭源产品,寻找其会话管理器的替代实现不仅困难,而且通常是不可能的。

Spring Session提供了一种与应用程序服务器无关的方式来配置Servlet规范范围内的可插入会话数据存储,而不必依赖于任何应用程序服务器特定的API。 这意味着Spring Session可与实现servlet规范的所有应用服务器(Tomcat,Jetty,WebSphere,WebLogic,JBoss)一起使用,并且在所有应用服务器上以完全相同的方式进行配置非常容易。 您还可以选择最能满足您需求的外部会话数据存储。 这使Spring Session成为理想的迁移工具,可以帮助您将传统的JavaEE应用程序作为12个要素的应用程序迁移到云中。

每个用户多个帐户

想象一下,您正在example.com上运行面向公众的Web应用程序,其中一些人类用户已经创建了多个帐户。 例如,用户Jeff Lebowski可能有两个帐户thedude@example.com和lebowski@example.com。 与其他Java Web应用程序一样,您可以使用HttpSession跟踪应用程序状态,例如当前登录的用户。 因此,当人类用户想要从thedude@example.com切换到lebowski@example.com时,他们必须注销然后重新登录。

使用Spring Session,为每个用户配置多个HTTP会话很简单,使用户无需注销和登录即可在thedude@example.com和lebowski@example.com之间切换。

多级安全性预览

假设您正在构建具有复杂的自定义授权的Web应用程序,其中应用程序UI根据分配给用户的角色和权限进行调整。

例如,假设应用程序具有四个安全级别:公共,机密,机密和最高机密。 当用户登录到应用程序时,系统会计算出其最高的安全级别,并仅向他们显示其级别或更低级别的数据。 因此具有公开权限的用户可以查看公共文档,而具有秘密权限的用户可以查看公共文档,机密文档和秘密文档等。为了使用户界面更加友好,应用程序应允许用户预览应用程序的外观例如较低的安全级别。 例如,最高机密用户可以将应用程序从最高机密模式切换到机密模式,以从具有机密权限的用户的角度查看情况。

典型的Web应用程序在HTTP会话中存储当前用户的身份及其角色。 但是由于一个Web应用程序每个登录用户只能有一个会话,因此我们将无法在角色之间进行切换,而不必注销用户然后再将其重新登录,或者必须自己为每个用户会话实现多个会话。

借助Spring Session,您可以轻松地为每个登录用户创建多个会话,并且每个会话完全独立于其他会话,因此实现预览功能非常简单。 例如,当前以最高机密角色登录的用户可以通过让应用程序创建新会话来以保密模式预览该应用程序,其中最高安全角色是秘密而不是最高机密。

使用Web套接字时保持登录状态

想象一下,当用户在example.com上登录到您的Web应用程序时,他们可以使用可在websocket上运行HTML5聊天客户端彼此聊天。 根据Servlet规范,通过websocket到达的请求不会使HTTP会话保持活动状态,因此,当您的用户聊天时,HTTP会话的倒数计时器将开始计时。 最终,HTTP会话将终止,即使从用户的角度来看,他们正在积极使用该应用程序。 当HTTP会话到期时,websocket连接将关闭。

使用Spring Session,您可以轻松地确保websocket请求和常规HTTP请求都为您的用户保持HTTP会话的活动状态。

访问非Web请求的会话数据

想象一下,您的应用程序提供了两种访问方法: 一种通过HTTP使用REST API,另一种通过RabbitMQ通过AMQP消息使用。 执行消息处理代码的线程无权访问应用程序服务器的HttpSession,因此您必须想出一个自定义解决方案来通过自己的机制来访问HTTP会话中的数据。

使用Spring Session,只要知道会话的ID,就可以从应用程序中的任何线程访问Spring Session。 因此,Spring Session具有比Servlet HTTP会话管理器更丰富的API,因为您只需知道会话ID就可以检索非常特定的会话。 例如,传入消息可能包含用户ID标头字段,您可以使用该字段直接检索会话。

Spring Session如何工作

现在,我们已经讨论了传统应用程序服务器HTTP会话管理不足的各种用例,让我们看一下Spring Session如何解决问题。

Spring会议架构

实施会话管理器时,必须解决两个关键问题。 首先,如何创建可以可靠且有效地存储数据的群集高可用性会话。 其次,如何确定哪个会话实例与哪个传入请求相关联,该请求是HTTP,WebSocket,AMQP还是其他协议。 本质上,关键问题是:如何通过用于发出请求的协议传输会话ID?

Spring Session假定将数据存储在高可用性可伸缩群集中的第一个问题已被各种数据存储(例如Redis,GemFire,Apache Geode等)很好地解决,因此Spring Session应该定义一组标准的接口,可以实现调解对底层数据存储的访问。 Spring Session定义了以下关键接口: Session, ExpiringSession,SessionRepository ,它们针对不同的数据存储实现。

  • org.springframework.session.Session是一个定义会话基本功能(例如设置和删除属性)的接口。 该接口不对基础技术进行任何假设,因此,与Servlet HttpSession相比,可以在更广泛的情况下使用该接口。
  • org.springframework.session.ExpiringSession扩展了Session接口以提供可用于确定会话是否到期的属性。 RedisSession是此接口的示例实现。
  • org.springframework.session.SessionRepository定义用于创建,保存,删除和检索会话的方法。 实际将Session实例保存到数据存储中的逻辑将在此接口的实现中进行编码。 例如,RedisOperationsSessionRepository是此接口的实现,该接口在Redis中创建,存储和删除会话。

Spring Session假设将请求与特定会话实例相关联的问题是协议特定的,因为客户端和服务器需要就在请求/响应周期内传输会话ID的方式达成共识。 例如,如果请求通过HTTP到达,则可以使用HTTP cookie或HTTP标头将会话与请求关联。 如果正在使用HTTPS,则可以使用SSL会话ID将请求与会话相关联。 如果正在使用JMS,则可以使用JMS标头存储请求和响应之间的会话ID。

对于HTTP协议,Spring Session定义了一个具有两种默认实现的HttpSessionStrategy接口: CookieHttpSessionStrategy (使用HTTP cookie将请求与会话ID关联)和HeaderHttpSessionStrategy (使用自定义HTTP标头将请求与会话关联)。

下一节将详细介绍Spring Session如何通过HTTP工作。

在本出版物发行时,当前的GA发行版Spring Session 1.0.2附带了使用Redis的Spring Session的实现,以及支持任何分布式Map(例如Hazelcast)的基于Map的实现。 对Spring Session数据存储的支持实现相对容易,并且各种数据存储都有社区实现。

通过HTTP的Spring会议

HTTP上的Spring Session被实现为标准Servlet过滤器,必须将其配置为拦截所有Web应用程序请求,并且应该是过滤器链中的第一个过滤器。 Spring Session过滤器负责确保将对javax.servlet.http.HttpServletRequest上的getSession()所有后续代码调用都传递给Spring Session HttpSession实例,而不是应用程序服务器的默认HttpSession。

理解这一点的最简单方法是检查Spring Session使用的实际源代码。 首先,我们将从用于实现Spring Session的标准servlet扩展点的背景知识入手。

Servlet 2.3规范在2001年引入了ServletRequestWrapperJavadoc指出ServletRequestWrapper ”提供了ServletRequest接口的便捷实现,希望将请求适应Servlet的开发人员可以将其子类化。 此类实现Wrapper或Decorator模式。 方法默认为调用包装的请求对象。” 下面的代码示例摘自Tomcat,并显示了如何实现ServletRequestWrapper。

public class ServletRequestWrapper implements ServletRequest {

    private ServletRequest request;

    /**
     * Creates a ServletRequest adaptor wrapping the given request object. 
     * @throws java.lang.IllegalArgumentException if the request is null
     */
    public ServletRequestWrapper(ServletRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Request cannot be null");   
        }
        this.request = request;
    }

    public ServletRequest getRequest() {
        return this.request;
    }
    
    public Object getAttribute(String name) {
        return this.request.getAttribute(name);
    }

    // rest of the method omitted for readability 
}

Servlet 2.3规范还定义了HttpServletRequestWrapper ,它是ServletRequestWrapper的子类,可用于快速提供HttpServletRequest的自定义实现,以下代码是从Tomcat中提取的,并显示了HttpServletRequesWrapper类的工作方式。

public class HttpServletRequestWrapper extends ServletRequestWrapper 
    implements HttpServletRequest {

    public HttpServletRequestWrapper(HttpServletRequest request) {
	    super(request);
    }
    
    private HttpServletRequest _getHttpServletRequest() {
 	   return (HttpServletRequest) super.getRequest();
    }
  
    public HttpSession getSession(boolean create) {
     return this._getHttpServletRequest().getSession(create);
    }
   
    public HttpSession getSession() {
      return this._getHttpServletRequest().getSession();
    }
  // rest of the methods are omitted for readability  
}

因此,这些包装器类使编写扩展HttpServletRequest代码成为可能,从而覆盖了返回HttpSession的方法,以返回由外部存储库支持的实现。下面的代码示例摘自Spring Session项目,但我替换了原始参考注释和我自己的注释来解释本文上下文中的代码,因此请确保参考下面的代码片段中的注释。

/*
 * Notice that the Spring Session project defines a class that extends the
 * standard HttpServletRequestWrapper in order to override the
 * HttpServletRequest methods that are related to sessions.
 */
private final class SessionRepositoryRequestWrapper
   extends HttpServletRequestWrapper {

   private HttpSessionWrapper currentSession;
   private Boolean requestedSessionIdValid;
   private boolean requestedSessionInvalidated;
   private final HttpServletResponse response;
   private final ServletContext servletContext;

   /*
   * Notice that the constructor is pretty simple; it takes arguments that it
   * will need later and delegates to the HttpServletRequestWrapper that it
   * extends from
   */
   private SessionRepositoryRequestWrapper(
      HttpServletRequest request,
      HttpServletResponse response,
      ServletContext servletContext) {
     super(request);
     this.response = response;
     this.servletContext = servletContext;
   }

   /*
   * This is where the Spring Session project stops delegating calls to the
   * app server that it is running in, and instead implements it's own logic
   * to return an instance of of HttpSession that is backed by an
   * external data store.
   *
   * The basic implementation checks if it already has a session. If so it
   * returns it, otherwise it will check if a session id exists for the
   * current request. If so it will use the session id to load the session
   * from its SessionRepository. If there is no session in the session
   * repository or there is no current session id attached to the request,
   * it will create a new session and persist it in the session repository.
   */
   @Override
   public HttpSession getSession(boolean create) {
     if(currentSession != null) {
       return currentSession;
     }
     String requestedSessionId = getRequestedSessionId();
     if(requestedSessionId != null) {
       S session = sessionRepository.getSession(requestedSessionId);
       if(session != null) {
         this.requestedSessionIdValid = true;
         currentSession = new HttpSessionWrapper(session, getServletContext());
         currentSession.setNew(false);
         return currentSession;
       }
     }
     if(!create) {
       return null;
     }
     S session = sessionRepository.createSession();
     currentSession = new HttpSessionWrapper(session, getServletContext());
     return currentSession;
   }

   @Override
   public HttpSession getSession() {
     return getSession(true);
   }
}

Spring Session定义了一个实现Servlet Filter接口的SessionRepositoryFilter 。 我已在下面的代码片段中提取了过滤器的关键部分,并添加了一些注释来解释本文上下文中的代码,因此,请再次确保在下面的代码片段中阅读注释。

/*
 * The SessionRepositoryFilter is just a standard ServletFilter that is
 * implemented by extending a helper base class.
 */
public class SessionRepositoryFilter < S extends ExpiringSession >
    extends OncePerRequestFilter {

	/*
	 * This method is where the magic happens. The method creates an
	 * instance of the wrapped request we examined above, as well as
	 * a wrapped response object, then invokes the rest of the filter chain.
	 * The key thing is that when application code that executes after this
	 * filter requests a session, it will be given the Spring Session
	 * HttpServletSession instance that is backed by the external data store.
	 */
	protected void doFilterInternal(
	    HttpServletRequest request,
	    HttpServletResponse response,
	    FilterChain filterChain) throws ServletException, IOException {

		request.setAttribute(SESSION_REPOSITORY_ATTR, sessionRepository);

		SessionRepositoryRequestWrapper wrappedRequest =
		  new SessionRepositoryRequestWrapper(request,response,servletContext);

		SessionRepositoryResponseWrapper wrappedResponse =
		  new SessionRepositoryResponseWrapper(wrappedRequest, response);

		HttpServletRequest strategyRequest =
		     httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);

		HttpServletResponse strategyResponse =
		     httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);

		try {
			filterChain.doFilter(strategyRequest, strategyResponse);
		} finally {
			wrappedRequest.commitSession();
		}
	}
}

本节的主要结论是,基于HTTP的Spring Session只是一个普通的ServletFilter ,它使用Servlet规范的标准功能来交付其功能。 因此,除非使用javax.servlet.http.HttpSessionListener否则您应该能够获取现有的war文件并使其使用Spring Session而不更改现有代码。 Spring Session 1.0不支持HttpSessionListener但是已将其添加到Spring Session 1.1 M1版本中,您可以在此处找到详细信息。

配置Spring会话

在Web项目上配置Spring Session是一个四步过程。

  • 设置将与Spring Session一起使用的数据存储
  • 将Spring Session jar文件添加到您的Web应用程序
  • 将Spring Session过滤器添加到Web应用程序的配置中
  • 配置从Spring Session到选定会话数据存储的连接

Spring Session附带了对Redis的内置支持。 有关设置和安装Redis的详细信息,请参见此处

有两种常用方法可以完成上述Spring Session配置步骤。 第一种方法是使用Spring Boot自动配置Spring Session。 配置Spring Session的第二种方法是手动完成每个配置步骤。

使用Maven或Gradle这样的依赖管理器可以轻松地将Spring Session依赖添加到您的应用程序中。 如果您使用的是Maven和Spring Boot,则可以在pom.xml中使用以下依赖项

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session</artifactId>
    <version>1.0.2.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-redis</artifactId>
</dependency>

spring-boot-starter-redis依赖性确保应用程序中包含使用redis所需的所有jar,以便可以使用Spring Boot自动配置它们。 spring-session依赖关系引入了Spring Session jar。

只需在Spring Boot配置类上使用@EnableRedisHttpSession批注,Spring Boot即可自动配置Spring Session Servlet过滤器,如下面的代码片段所示。

@SpringBootApplication
@EnableRedisHttpSession
public class ExampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(ExampleApplication.class, args);
    }
}

通过将以下配置设置添加到Spring Boot application.properties文件中,可以配置从Spring Session到Redis的连接。

spring.redis.host=localhost
spring.redis.password=secret
spring.redis.port=6379

Spring Boot提供了广泛的基础架构来配置与Redis的连接,并且可以使用任何定义与Redis数据库的连接的方式。 您可以找到有关使用Spring Session和Spring Boot的逐步指南

这里可以找到有关如何使用web.xml配置传统Web应用程序以使用Spring Session的教程。

此处可以找到有关如何配置不使用web.xml进行配置的传统war文件的教程。

默认情况下,Spring Session将使用HTTP cookie存储会话ID,但是您可以将Spring Session配置为使用自定义HTTP标头,例如x-auth-token: 0dc1f6e1-c7f1-41ac-8ce2-32b6b3e57aa3这在构建时非常有用REST API。 完整的分步教程可以在这里找到。

使用Spring会议

一旦配置了Spring Session,就可以使用标准的Servlet API与之交互。 例如,以下代码定义了一个使用标准Servlet会话API来访问会话的Servlet。

@WebServlet("/example")
public class Example extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest request, HttpServletResponse response)
      throws ServletException, IOException {

    // obtain a session using the normal servlet API and the underlying
    // session comes from Spring Session and will be stored in Redis or
    // another data source of your choice

    HttpSession session = request.getSession();
    String value = session.getAttribute(“someAttribute�?);

  }
}

每个浏览器多个会话

Spring Session通过使用称为_s的会话别名参数来跟踪每个用户的多个会话。 例如,如果请求到达http://example.com/doSomething?_s=0,则Spring Session将读取_s参数的值,并使用它来确定该请求是针对默认会话的。

如果请求到达http://example.com/doSomething? _s=1 Spring Session将知道该请求是针对别名为1的会话。如果该请求未指定_s参数,例如http://example.com/doSomething 则Spring Session会将其视为默认会话,即_s=0

为了在每个浏览器中创建一个新会话,只需像通常那样调用javax.servlet.http.HttpServletRequest.getSession() ,Spring Session将返回正确的会话或使用标准Servlet规范中的语义创建一个新会话。 。 下表提供了有关在同一浏览器窗口中不同网址的getSession()行为的示例。

HTTP请求网址

会话别名

getSession()行为

example.com/resource

0

如果存在与别名0关联的会话,则返回它,否则创建一个新会话并将其与别名0关联。

example.com/resource?_s=1

1个

如果存在与别名1关联的会话,则返回它,否则创建一个新会话并将其与别名1关联。

example.com/resource?_s=0

0

如果存在与别名0关联的会话,则返回它,否则创建一个新会话并将其与别名0关联。

example.com/resource?_s=abc

abc

如果存在与别名abc关联的会话,则返回它,否则创建一个新会话并将其与别名abc关联

如上表所示,会话别名不必为整数,因此它必须与发布给该用户的所有其他会话别名不同。 但是整数会话别名可能是最容易使用的,Spring Session提供了HttpSessionManager接口,该接口提供了一些使用会话别名的实用方法。

您可以通过在HttpServletRequest的属性名称“org.springframework.session.web.http.HttpSessionManager”下查找当前的HttpSessionManager来访问它。 下面的示例说明了如何获取对HttpSessionManager的访问以及示例注释中说明的关键方法的行为。

@WebServlet("/example")
public class Example extends HttpServlet {

  @Override
  protected void doGet(HttpServletRequest request,HttpServletResponse response)
  throws ServletException, IOException {

    /*
     * obtain a reference to the Spring Session session manager by looking
     * it up in the request under the key
     * org.springframework.session.web.http.HttpSessionManager
     */

    HttpSessionManager sessionManager=(HttpSessionManager)request.getAttribute(
        "org.springframework.session.web.http.HttpSessionManager");

    /*
     * Use the session manager to find out what the requested session alias
     * is. By default the session alias is included as a request parameter
     * _s in the url. For example http://localhost:8080/example?_s=1 would
     * cause the code below to print "Requested Session Alias is: 1"
     */
    String requestedSessionAlias=sessionManager.getCurrentSessionAlias(request);
    System.out.println("Requested Session Alias is:  " + requestedSessionAlias);

    /* Return a unique session alias id that is not currently in use
     * by the browser sending a request. This method does NOT create a
     * new session you need to call request.getSession() to create a
     * new session.
     */
    String newSessionAlias = sessionManager.getNewSessionAlias(request);

    /* Use the newly created session alias to create a URL that includes
     * the _s parameter. For example if the newSessionAlias had a value
     * of 2 then the method below should return /inbox?_s=3
     */

    String encodedURL = sessionManager.encodeURL("/inbox", newSessionAlias);
    System.out.println(encodedURL);

    /* Return a map of session aliases to session ids for the browser
     * making the request.
     */
    Map < String, String > sessionIds = sessionManager.getSessionIds(request);
  }
}

结论

Spring Session将创新带回了企业Java会话管理空间,使您可以轻松地:

  • 编写水平可扩展的云本机应用程序。
  • 将会话状态的存储卸载到专用的外部会话存储中,例如Redis或Apache Geode,它们以独立于应用程序服务器的方式提供高质量的集群。
  • 用户通过WebSocket发出请求时,请保持HttpSession处于活动状态。
  • 从非Web请求处理代码(例如JMS消息处理代码)访问会话数据。
  • 每个浏览器支持多个会话,从而轻松构建更丰富的最终用户体验。
  • 控制客户端和服务器之间会话ID的交换方式,从而轻松编写Restful API,可以从HTTP标头中提取会话ID,而不必依赖Cookie。

如果您打算移出传统的重量级应用程序服务器,但由于使用的是应用程序服务器的会话集群功能而受到阻碍,那么Spring Session是迈向轻量级容器(例如Tomcat,Jetty或Undertow)的重要一步。

参考资料

Spring会议项目

Spring会议教程指南

Websocket / HttpSession超时交互

网络研讨会重播: Spring Session介绍

翻译自: https://www.infoq.com/articles/Next-Generation-Session-Management-with-Spring-Session/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值