Spring Security框架中踢人下線技術探索
- 2021 年 4 月 20 日
- 筆記
- Session會話下線, Spring Boot, spring security, 會話剔除
1.背景
在某次項目的開發中,使用到了Spring Security許可權框架進行後端許可權開發的許可權校驗,底層集成Spring Session組件,非常方便的集成Redis進行分散式Session的會話集群部署。系統正式上線後,各個部署節點能夠非常方便的進行集群部署,用戶的Session會話資訊全部保存在Redis中間件庫中,開發者不用關心具體的實現,Spring Session組件已經全部集成好了。
但是在系統的用戶管理模組中,提供了對系統用戶帳號的刪除功能以及禁用功能,針對這兩個功能,需求方給出的具體要求是:
- 刪除:當管理員刪除當前用戶帳號時,如果當前帳號已經登錄系統,則需要剔除下線,並且不可登錄
- 禁用:當管理員對當前帳號禁用操作時,如果當前帳號已經登錄系統,則需要剔除下線,並且登錄時,提示當前帳號已禁用
2.需求分析
從上面的需求來看,不管是刪除還是禁用功能,都需要實現,如果當前帳號已經登錄系統,則需要剔除下線,而禁用操作只需要再登錄時給出提示資訊即可,這個在業務登錄方法中就可以實現,不必從底層框架進行考慮。
因此,從底層技術測進行考慮時,我們需要探索如何在Spring Security許可權框架中實現踢人下線的功能。
既然需求已經明確,從功能的實現可能性方面入手,我們則需要從幾個方面進行考慮:
- 1)、在Spring Security框架中,用戶登錄的Session會話資訊存儲在哪裡?
- 2)、在Spring Security框架中,Session會話如何存儲,主要存儲哪些資訊?
- 3)、如何根據帳號收集當前該帳號登錄的所有Session會話資訊?
- 4)、如何在服務端主動銷毀Session對象?
1)、在Spring Security框架中,用戶登錄的Session會話資訊存儲在哪裡?
如果我們不考慮分散式Session會話的情況,單體Spring Boot項目中,服務端Session會話肯定存儲在記憶體中,這樣的弊端是如果當前應用需要做負載均衡進行部署時,用戶請求服務端介面時,會存在Session會話丟失的情況,因為用戶登錄的會話資訊都存在JVM記憶體中,沒有進程之間的共享互通。
為了解決分散式應用Session會話不丟失的問題,Spring Session組件發布了,該組件提供了基於JDBC\Redis等中間件的方式,將用戶端的Session會話存儲在中間件中,這樣分散式應用獲取用戶會話時,都會從中間件去獲取會話Session,這樣也保證了服務可以做負載部署以保證Session會話不丟失。本文主要討論的也是這種情況,集成Redis中間件用來保存用戶會話資訊。
2)、在Spring Security框架中,Session會話如何存儲,主要存儲哪些資訊?
由於我們使用了Redis中間件,所以,在Spring Security許可權框架中產生的Session會話資訊,肯定存儲與Redis中,這點毫無疑問,那麼存儲了哪些資訊呢?我會在接下來的源碼分析中進行介紹
3)、如何根據帳號收集當前該帳號登錄的所有Session會話資訊?
我們從上面的需求分析中已經得知Session會話已經存儲在Redis中,那麼我們是否可以做這樣的假設,我們只需要根據Spring Security中在Redis中存儲的鍵值,找到和登錄用戶名相關的Redis快取數據,就可以通過調用Security封裝的方法進行獲取,得到當前登錄帳號的會話資訊呢?這個我們需要在源碼中去找到答案
4)、如何在服務端主動銷毀Session對象?
如果是單體的Spring Boot應用,Session資訊肯定存儲在JVM的記憶體中,服務端要主動銷毀Session對象只需要找到Security許可權框架如何存儲的就可以進行刪除。
在分散式的Spring Boot應用中,我們從上面已經得知Session會話資訊以及存儲在Redis中間件中,那麼我們只需要得到當前登錄的Session在Redis中的鍵值,就可以調用方法進行刪除操作,從而主動在服務端銷毀Session會話
3.源碼分析
在上面的需求分析中,我們已經提出了假設,並且根據假設,做出來技術性的判斷,接下來我們需要從Spring Security以及Spring Session組件的源碼中,去尋找我們需要的答案。
首先,我們在源碼分析前,我們需要找到入口,也就是我們在使用Spring Security框架,並且使用Spring Session組件時,我們如何使用的。
在pom.xml
文件中引入組件的依賴是必不可少的,如下:
<!--Spring Security組件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--Spring針對Redis操作組件-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--Spring Session集成Redis分散式Session會話-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
接下來,我們在Spring Boot項目中,需要添加@EnableRedisHttpSession
註解,以開啟Redis組件對Session會話的支援,該註解我們需要制定Spring Session在Redis中存儲的Redis命名空間,已經Session會話的時效性,示例程式碼如下:
@SpringBootApplication
@EnableRedisHttpSession(redisNamespace = "fish-admin:session",maxInactiveIntervalInSeconds = 7200)
public class FishAdminApplication {
static Logger logger= LoggerFactory.getLogger(FishAdminApplication.class);
public static void main(String[] args) throws UnknownHostException {
ConfigurableApplicationContext application=SpringApplication.run(FishAdminApplication.class, args);
Environment env = application.getEnvironment();
String host= InetAddress.getLocalHost().getHostAddress();
String port=env.getProperty("server.port");
logger.info("\n----------------------------------------------------------\n\t" +
"Application '{}' is running! Access URLs:\n\t" +
"Local: \t\t//localhost:{}\n\t" +
"External: \t//{}:{}\n\t"+
"Doc: \t//{}:{}/doc.html\n\t"+
"----------------------------------------------------------",
env.getProperty("spring.application.name"),
env.getProperty("server.port"),
host,port,
host,port);
}
在上面的程式碼中,我們指定Redis的命名空間是fish-admin:session
,默認最大失效7200
秒。
如果開發者默認不指定這兩個屬性的話,命名空間默認值是spring:session
,默認最大時效則是1800
秒
在上面我們已經說過了,既然是看源碼,我們需要找到入口,這是看源碼最好的方式,我們在使用Spring Session組件時,需要使用@EnableRedisHttpSession
註解,那麼該註解就是我們需要重點關注的對象,我們需要搞清楚,該註解的作用是什麼?
EnableRedisHttpSession.java
部分源碼如下:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)
@Configuration(proxyBeanMethods = false)
public @interface EnableRedisHttpSession {
//more property..
}
在該註解中,我們可以看到,最關鍵的是在該註解之上,使用@Import
註解導入了RedisHttpSessionConfiguration.java
配置類,如果你經常翻看Spring Boot相關的源碼,你會敏銳的察覺到,該配置類就是我們最終要找的類
先來看該類的UML圖,如下:
該類實現了Spring框架中很多Aware
類型介面,Aware
類型的介面我們都知道,Spring容器在啟動創建實體Bean後,會調用Aware
系列的set方法傳參賦值
當然,最核心的,我們從源碼中可以看到,是Spring Session組件會向Spring容器中注入兩個實體Bean
,程式碼如下:
@Bean
public RedisIndexedSessionRepository sessionRepository() {
RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
RedisIndexedSessionRepository sessionRepository = new RedisIndexedSessionRepository(redisTemplate);
sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
if (this.indexResolver != null) {
sessionRepository.setIndexResolver(this.indexResolver);
}
if (this.defaultRedisSerializer != null) {
sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
}
sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
if (StringUtils.hasText(this.redisNamespace)) {
sessionRepository.setRedisKeyNamespace(this.redisNamespace);
}
sessionRepository.setFlushMode(this.flushMode);
sessionRepository.setSaveMode(this.saveMode);
int database = resolveDatabase();
sessionRepository.setDatabase(database);
this.sessionRepositoryCustomizers
.forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
return sessionRepository;
}
@Bean
public RedisMessageListenerContainer springSessionRedisMessageListenerContainer(
RedisIndexedSessionRepository sessionRepository) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(this.redisConnectionFactory);
if (this.redisTaskExecutor != null) {
container.setTaskExecutor(this.redisTaskExecutor);
}
if (this.redisSubscriptionExecutor != null) {
container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
}
container.addMessageListener(sessionRepository,
Arrays.asList(new ChannelTopic(sessionRepository.getSessionDeletedChannel()),
new ChannelTopic(sessionRepository.getSessionExpiredChannel())));
container.addMessageListener(sessionRepository,
Collections.singletonList(new PatternTopic(sessionRepository.getSessionCreatedChannelPrefix() + "*")));
return container;
}
RedisIndexedSessionRepository
以及RedisMessageListenerContainer
的實體Bean
RedisMessageListenerContainer
:該類是Redis的消息通知回調機制實體類,Redis提供了針對不同Key的操作回調消息通知,比如常見的刪除key、key過期等事件的回調,在Spring Session組件中注入該實體Bean,從程式碼中也可以看出是用來監聽處理Session會話的過期以及刪除事件RedisIndexedSessionRepository
:該類是Spring Session組件提供基於Redis的針對Session會話一系列操作的具體實現類,是我們接下來源碼分析的重點。
先來看RedisIndexedSessionRepository
類的UML類圖結構,如下圖:
RedisIndexedSessionRepository
實現了FindByIndexNameSessionRepository
介面,而FindByIndexNameSessionRepository
介面又繼承Spring Security許可權框架提供的頂級SessionRepository
介面,UML類圖中,我們可以得到幾個重要的資訊:
RedisIndexedSessionRepository
擁有創建Session會話、銷毀刪除Session會話的能力RedisIndexedSessionRepository
由於實現自FindByIndexNameSessionRepository
介面,而該介面提供了根據PrincipalName
查找Session會話的能力- 擁有Redis回調事件的處理消息能力,因為實現了
MessageListener
介面
SessionRepository
是Spring Security提供的頂級介面,源碼如下:
public interface SessionRepository<S extends Session> {
/**
* Creates a new {@link Session} that is capable of being persisted by this
* {@link SessionRepository}.
*
* <p>
* This allows optimizations and customizations in how the {@link Session} is
* persisted. For example, the implementation returned might keep track of the changes
* ensuring that only the delta needs to be persisted on a save.
* </p>
* @return a new {@link Session} that is capable of being persisted by this
* {@link SessionRepository}
*/
S createSession();
/**
* Ensures the {@link Session} created by
* {@link org.springframework.session.SessionRepository#createSession()} is saved.
*
* <p>
* Some implementations may choose to save as the {@link Session} is updated by
* returning a {@link Session} that immediately persists any changes. In this case,
* this method may not actually do anything.
* </p>
* @param session the {@link Session} to save
*/
void save(S session);
/**
* Gets the {@link Session} by the {@link Session#getId()} or null if no
* {@link Session} is found.
* @param id the {@link org.springframework.session.Session#getId()} to lookup
* @return the {@link Session} by the {@link Session#getId()} or null if no
* {@link Session} is found.
*/
S findById(String id);
/**
* Deletes the {@link Session} with the given {@link Session#getId()} or does nothing
* if the {@link Session} is not found.
* @param id the {@link org.springframework.session.Session#getId()} to delete
*/
void deleteById(String id);
}
該介面提供四個方法:
createSession
:創建Session會話save
:保存Session會話findById
:根據SessionId
查找獲取Session會話對象資訊deleteById
:根據SessionId
進行刪除
FindByIndexNameSessionRepository
源碼主要是提供根據帳號名稱進行查詢的功能,如下:
public interface FindByIndexNameSessionRepository<S extends Session> extends SessionRepository<S> {
/**
* 當前存儲的用戶名前綴,使用Redis進行存儲時,存儲的key值是:redisNamespace+
*/
String PRINCIPAL_NAME_INDEX_NAME = FindByIndexNameSessionRepository.class.getName()
.concat(".PRINCIPAL_NAME_INDEX_NAME");
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the specified index name index value.
* @param indexName the name of the index (i.e.
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME})
* @param indexValue the value of the index to search for.
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified index name and index value. If no
* results are found, an empty {@code Map} is returned.
*/
Map<String, S> findByIndexNameAndIndexValue(String indexName, String indexValue);
/**
* Find a {@link Map} of the session id to the {@link Session} of all sessions that
* contain the index with the name
* {@link FindByIndexNameSessionRepository#PRINCIPAL_NAME_INDEX_NAME} and the
* specified principal name.
* @param principalName the principal name
* @return a {@code Map} (never {@code null}) of the session id to the {@code Session}
* of all sessions that contain the specified principal name. If no results are found,
* an empty {@code Map} is returned.
* @since 2.1.0
*/
default Map<String, S> findByPrincipalName(String principalName) {
return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
}
}
該介面最核心的功能是提供了根據用戶名查找獲取Session會話的介面,這對我們後面實現踢人功能很有幫助。
通過查看SessionRepository
介面以及FindByIndexNameSessionRepository
介面的源碼我們得知:
- Redis的實現最終實現了這兩個介面,因此獲得了基於Redis中間件創建及銷毀Session會話的能力
- 根據帳號去查找當前的所有登錄會話Session符合我們最終需要服務端主動踢人下線的功能需求。
接下來我們只需要關注RedisIndexedSessionRepository
的實現即可。首先來看findByPrincipalName
方法,源碼如下:
@Override
public Map<String, RedisSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
//如果名稱不匹配,則直接回饋空集合Map
if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) {
return Collections.emptyMap();
}
//獲取拼裝的Key值
String principalKey = getPrincipalKey(indexValue);
//從Redis中獲取該Key值的成員數
Set<Object> sessionIds = this.sessionRedisOperations.boundSetOps(principalKey).members();
//初始化Map集合
Map<String, RedisSession> sessions = new HashMap<>(sessionIds.size());
//循環遍歷
for (Object id : sessionIds) {
//根據id查找Session會話
RedisSession session = findById((String) id);
if (session != null) {
sessions.put(session.getId(), session);
}
}
return sessions;
}
String getPrincipalKey(String principalName) {
return this.namespace + "index:" + FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME + ":"
+ principalName;
}
接下來我們看刪除Session會話的方法實現:
@Override
public void deleteById(String sessionId) {
//根據sessionId獲取Session會話
RedisSession session = getSession(sessionId, true);
if (session == null) {
return;
}
//從Redis中移除所有存儲的針對principal的key值
cleanupPrincipalIndex(session);
//Redis中刪除SessionId所對應的key值
this.expirationPolicy.onDelete(session);
//移除Session會話創建時,存儲的過期key值
String expireKey = getExpiredKey(session.getId());
this.sessionRedisOperations.delete(expireKey);
//設置當前session會話最大存活時間為0
session.setMaxInactiveInterval(Duration.ZERO);
//執行save方法
save(session);
}
從上面的程式碼中,我們已經知道了Spring Session組件對於Session相關的處理方法,其實我們基於上面的兩個核心方法,我們已經獲得了踢人下線的能力,但是,既然RedisIndexedSessionRepository
實現了MessageListener
介面,我們需要繼續跟蹤一下該介面的具體實現方法,我們直接來看onMessage
方法,程式碼如下:
@Override
public void onMessage(Message message, byte[] pattern) {
byte[] messageChannel = message.getChannel();
byte[] messageBody = message.getBody();
String channel = new String(messageChannel);
if (channel.startsWith(this.sessionCreatedChannelPrefix)) {
// TODO: is this thread safe?
@SuppressWarnings("unchecked")
Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer.deserialize(message.getBody());
handleCreated(loaded, channel);
return;
}
String body = new String(messageBody);
if (!body.startsWith(getExpiredKeyPrefix())) {
return;
}
boolean isDeleted = channel.equals(this.sessionDeletedChannel);
if (isDeleted || channel.equals(this.sessionExpiredChannel)) {
int beginIndex = body.lastIndexOf(":") + 1;
int endIndex = body.length();
String sessionId = body.substring(beginIndex, endIndex);
RedisSession session = getSession(sessionId, true);
if (session == null) {
logger.warn("Unable to publish SessionDestroyedEvent for session " + sessionId);
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
}
cleanupPrincipalIndex(session);
if (isDeleted) {
handleDeleted(session);
}
else {
handleExpired(session);
}
}
}
private void handleDeleted(RedisSession session) {
publishEvent(new SessionDeletedEvent(this, session));
}
private void handleExpired(RedisSession session) {
publishEvent(new SessionExpiredEvent(this, session));
}
private void publishEvent(ApplicationEvent event) {
try {
this.eventPublisher.publishEvent(event);
}
catch (Throwable ex) {
logger.error("Error publishing " + event + ".", ex);
}
}
在onMessage
方法中,最核心的是最後一個判斷,分別執行handleDeleted
和handleExpired
方法,從源碼中我們可以看到,噹噹前Session會話被刪除或者失效時,Spring Session會通過ApplicationEventPublisher
廣播一個事件,分別處理SessionExpiredEvent
和SessionDeletedEvent
事件
這是Spring Session組件為開發者預留的針對Session會話的Event事件,如果開發者對於當前的Sesssion
會話的刪除或者失效有特殊的處理需求,則可以通過監聽該事件進行處理。
例如,開發者針對Session會話的操作都需要做業務操作,記錄日誌保存到DB資料庫中,此時,開發者只需要使用Spring提供的EventListener
實現就可以很輕鬆的實現,示例程式碼如下:
@Component
public class SecuritySessionEventListener {
@EventListener
public void sessionDestroyed(SessionDestroyedEvent event) {
//session銷毀事件處理方法...
}
@EventListener
public void sessionCreated(SessionCreatedEvent event) {
//session創建會話事件處理方法...
}
@EventListener
public void sessionExired(SessionExpiredEvent event) {
//session會話過期事件處理方法...
}
}
4.解決方案
我們分析了Spring Session針對Session基於Redis的實現,接下來,我們從源碼中已經知道了該如何查找Session會話以及銷毀會話的方法,此時,我們可以來改造我們的框架程式碼了
創建SessionService
介面,程式碼如下:
public interface SessionService {
/**
*
* @param account
* @return
*/
boolean hasLogin(String account);
/**
* 根據帳號查找當前session會話
* @param account 帳號
* @return
*/
Map<String, ? extends Session> loadByAccount(String account);
/**
* 銷毀當前session會話
* @param account
*/
void destroySession(String account);
}
聲明該介面主要包含3個方法:
- hasLogin:通過傳遞登錄帳號,判斷該帳號是否已經登錄過,該方法是一個業務的延伸,比如我們對當前帳號判斷是否已經登錄過,如果登錄則提示需要退出才能繼續登錄的操作等
- loadByAccount:根據登錄帳號獲取當前已經登錄的Session會話Map集合
- destroySession:根據登錄帳號銷毀當前所有該帳號的Session會話資訊,此介面和產品經理要求的踢人下線操作一致
接下來就是實現類,由於我們是基於Redis來處理,因此,我們需要將源碼分析中的RedisIndexedSessionRepository
實體Bean進行引入,藉助該類實現該介面方法
RedisSessionService
方法實現如下:
/**
* SpringSession集成底層Redis實現,如果底層分散式會話保持方式不是基於Redis,則該類無法正常使用
* @author <a href="mailto:[email protected]">[email protected]</a>
* 2021/04/20 16:23
* @since:fish 1.0
*/
public class RedisSessionService implements SessionService {
Logger logger= LoggerFactory.getLogger(RedisSessionService.class);
final RedisIndexedSessionRepository redisIndexedSessionRepository;
final ApplicationEventPublisher applicationEventPublisher;
public RedisSessionService(RedisIndexedSessionRepository redisIndexedSessionRepository, ApplicationEventPublisher applicationEventPublisher) {
this.redisIndexedSessionRepository = redisIndexedSessionRepository;
this.applicationEventPublisher = applicationEventPublisher;
}
@Override
public boolean hasLogin(String account) {
return CollectionUtil.isNotEmpty(loadByAccount(account));
}
@Override
public Map<String, ? extends Session> loadByAccount(String account) {
logger.info("收集當前登錄會話session,帳號:{}",account);
return redisIndexedSessionRepository.findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,account);
}
@Override
public void destroySession(String account) {
logger.info("銷毀當前登錄session會話,帳號:{}",account);
Map<String,? extends Session> sessionMap=loadByAccount(account);
if (CollectionUtil.isNotEmpty(sessionMap)){
logger.info("當前登錄會話size:{}",sessionMap.size());
for (Map.Entry<String,? extends Session> sessionEntry:sessionMap.entrySet()){
String key=sessionEntry.getKey();
Session session=sessionEntry.getValue();
logger.info("destroy session key:{}",key);
//刪除
redisIndexedSessionRepository.deleteById(session.getId());
//廣播Session會話銷毀事件
applicationEventPublisher.publishEvent(new SessionDestroyedEvent(redisIndexedSessionRepository,session));
}
}
}
}
在destroySession
方法實現中,首先根據帳號獲取當前所有登錄會話資訊,如果會話不為空,則遍歷會話Map集合,執行刪除會話操作,並且通過applicationEventPublisher
廣播一個會話被銷毀的事件。該廣播事件非必須,但是從程式碼的全局進行考慮,還是需要加上
接下來,我們就可以將該類注入到Spring的容器中的,注入實體Bean程式碼如下:
@Bean
public RedisSessionService sessionService(RedisIndexedSessionRepository redisIndexedSessionRepository, ApplicationEventPublisher applicationEventPublisher){
return new RedisSessionService(redisIndexedSessionRepository,applicationEventPublisher);
}
PS:我們為什麼需要創建介面而不是直接創建class的方式通過
@Service
等註解進行注入,而是通過抽象介面實現類的方式,最終通過JavaConfig的方式進行注入呢?從程式碼的耦合度上來看,由於Spring Session提供處理基於Redis的能力處理Session會話之外,還提供了諸如JDBC\mongo等多元化的擴展方式,因此,為了程式碼解耦,通過抽象介面的方式是更合理的。
接下來,我們在我們的用戶管理的業務Service方法中就可以進行操作了
刪除用戶的業務Service方法
/**
* 根據主鍵id刪除用戶管理
* @param id 主鍵id
* @return 是否刪除成功
*/
@Override
public RestfulMessage<String> delete(Integer id) {
logger.info("根據主鍵id刪除用戶管理,id:{}",id);
FishUserInfo fishUserInfo=fishUserInfoMapper.selectByPrimaryKey(id);
assertArgumentNotEmpty(fishUserInfo,"請求數據非法");
int ret=fishUserInfoMapper.deleteByPrimaryKey(id);
//刪除成功,如果該角色在線,則強制剔除下線
if (ret>0){
logger.info("用戶會話剔除下線");
sessionService.destroySession(fishUserInfo.getAccount());
}
return ret>0?RestfulMessage.success("刪除成功"):RestfulMessage.error("刪除失敗");
}
禁用用戶
禁用用戶其實操作方法和刪除一樣,區別在於禁用操作只是將用戶在資料庫中的狀態進行變更,而刪除則是將該用戶的數據從資料庫DB中進行刪除。更新庫的用戶狀態後,調用destroySession刪除該帳號的所有Session會話操作即可