1. 程式人生 > >關於JSch的使用,執行ssh命令,檔案上傳和下載以及連線方式

關於JSch的使用,執行ssh命令,檔案上傳和下載以及連線方式

最近在做一個SAAS服務的專案,SAAS就是軟體即服務,具體可以去問度娘,然後底層呢需要遠端執行SSH命令來進行支援,最後就選擇了JSch來完成這個工作。

JSch是SSH2的一個純JAVA實現。它允許你連線到一個sshd伺服器,使用埠轉發,X11轉發,檔案傳輸等等。

大致需求就是能夠用java程式碼來實現對伺服器的一系列操控,其實就是執行一個業務流程的命令。

因為很多的環境配置,系統命令等都已經寫好了指令碼,我們用java程式碼要實現的就是,連上伺服器,執行命令,上傳、下載檔案,執行指令碼等一系列操作。。。

我的設想:

關於對伺服器操作的對外提供三個主要方法;

1、執行命令方法;

2、檔案上傳方法;

3、檔案下載方法。

由於檔案上傳和下載又涉及到進度問題,所以又提供了4個對外獲取檔案上傳和下載情況檢視的方法:

1、獲取檔案大小方法;

2、獲取檔案已傳輸大小方法;

3、判斷檔案是否已經傳輸完成方法;

4、獲取檔案傳輸百分比方法。

下面說下具體實現,一步步來說吧!

一、引入JSch的jar包(我是在POM新增的)

我引入的是JSch最新的包,附上:

<!-- https://mvnrepository.com/artifact/com.jcraft/jsch -->
<dependency>
<groupId>com.jcraft</groupId>
<artifactId>jsch</artifactId>
<version>0.1.53</version>
</dependency>
二、建工具類
public class SSHUtil{
	
	private static final Logger logger = LoggerFactory.getLogger(SSHUtil.class);

}
三、定義初始化常量(後面再說具體作用)
//獲取快取物件(這個是我們專案的快取系統,根據名稱空間去取存放的資料)
	private BaseCache<HashMap<String, Long>> cache;
	//名稱空間(用於從快取中存放和取出資料)
	private static final String CACHE_NAMESPACE = "syncssh";
	
	//預設session通道存活時間(我這裡定義的是5分鐘)
private static int SESSION_TIMEOUT = 300000;
//預設connect通道存活時間
private static int CONNECT_TIMEOUT = 1000;
//預設埠號
private static int DEFULT_PORT = 22;
    
//初始化物件
private JSch jsch = null;
private Session session = null;
//用於讀取的唯一ID(這個是用於讀取某個檔案上傳或者下載的進度,都放在快取空間,我肯定需要一個ID來找到是呼叫者是想找到哪個檔案的傳輸進度)
private String PROCESSID;
四、獲取連線

這裡要說明一下,獲取Session連線的方式有兩種,一種是直接賬號、密碼、IP地址、埠號就能連線,另外一種就是祕鑰方式連線(免密連線);

我們的SAAS服務叢集是基於一個SAAS管理伺服器,所有的子伺服器都是通過該伺服器進行管理,所以要操控子伺服器進行練級,就要在主伺服器上生成一個祕鑰對;

可以使用命令:     ssh-keygen -t rsa  來生成祕鑰;   生成的祕鑰是根據當前登入使用者的賬號生成的,也就是說跟當前登入使用者的賬號是繫結的,

然後就可以在 ssh資料夾中看到有一個  id_rsa  id_rsa.pub  兩個檔案(也有自定義名稱方法,具體可以查一下);

id_rsa是私鑰,id_rsa.pub是公鑰,接下來要做的就是把這個公鑰拷貝到子伺服器的ssh資料夾下

然後呼叫命令 : cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys   

這一步就是把該公鑰作為子伺服器的信任列表中;

要明白這樣做的意義就要先明白免密連線的具體步驟:

1.在A上生成公鑰私鑰。 

2.將公鑰拷貝給server B,要重新命名成authorized_keys(從英文名就知道含義了) 

3.Server A向Server B傳送一個連線請求。 

4.Server B得到Server A的資訊後,在authorized_key中查詢,如果有相應的使用者名稱和IP,則隨機生成一個字串,並用Server A的公鑰加密,傳送給Server A。 

5.Server A得到Server B發來的訊息後,使用私鑰進行解密,然後將解密後的字串傳送給Server B。Server B進行和生成的對比,如果一致,則允許免登入。 

那麼現在,子伺服器上就有有了主伺服器上的一個公鑰,而主伺服器本身存了一份私鑰。

下面講一下我對這兩個登入方式的定義:

該工具類的構造方法是不對外的,只提供兩個獲取例項的方法 ,分別是:

/**
     * 根據伺服器IP、賬號、密碼獲取jschUtil例項
     * @param user  	伺服器賬號
     * @param password	伺服器密碼
     * @param host		伺服器ip
     * @param port		埠號 (傳null預設22埠)
     * @return  賬號、密碼、IP不允許為null 有為null返回null
     * @throws JSchException
     * @author wangyijie
     * @data 2017年12月7日
     */
    public static SSHUtil getInstance(String user, String password, String host, Integer port) throws JSchException {
    	if (StringUtils.isBlank(user) || StringUtils.isBlank(password) || StringUtils.isBlank(host)) {
    		return null;
    	}
    	if (port == null) {
    		port = DEFULT_PORT;//這個是上面初始化的埠號22
    	}
        SSHInfo sshInfo = new SSHInfo(user, password, host, port);
        return new SSHUtil(sshInfo);
    }
    
    /**
     * 根據伺服器IP、賬號、祕鑰地址、祕鑰密碼獲取jschUtil例項
     * @param user  伺服器賬號
     * @param host	伺服器地址
     * @param port	埠號 (傳null預設22埠)
     * @param privateKey	祕鑰地址(本地存放的私鑰地址)
     * @param passphrase	祕鑰密碼
     * @return	賬號、IP、祕鑰地址不允許為null 有為null返回null
     * @throws JSchException
     * @author wangyijie
     * @data 2017年12月8日
     */
    public static SSHUtil getInstance( String user, String host, Integer port ,String privateKey ,String passphrase) throws JSchException{
    	if (StringUtils.isBlank(user) || StringUtils.isBlank(host) || StringUtils.isBlank(privateKey)) {
    		return null;
    	}
    	if (port == null) {
    		port = DEFULT_PORT;//這個是上面初始化的埠號22

    	}
    	SSHInfo sshInfo = new SSHInfo(user, host, port, privateKey, passphrase);
    	return new SSHUtil(sshInfo);
    }

這裡我們看到了我new了一個SSHInfo的物件

這個物件是獲取Session連線的重要物件,來看一下這個物件,這個類我是直接在工具類中定義的;(後面的需要的類也都是在該工具類中定義的,因為別的地方用不到)

private static class SSHInfo{
        private String user;		//伺服器賬號
        private String password;	//伺服器密碼
        private String host;		//地址
        private int port;		//埠號
        private String privateKey;  //祕鑰檔案路徑(本地存放的私鑰地址)
        private String passphrase;	//祕鑰的密碼(如果祕鑰進行過加密則需要)
        
        /**
         * 賬號密碼方式構造
         * @param user 賬號
         * @param password 密碼
         * @param host  IP地址
         * @param port  埠號
         */
        public SSHInfo(String user, String password, String host, int port) {
        	this.user = user;
        	this.password = password;
        	this.host = host;
        	this.port = port;
        }
        
        /**
         * 祕鑰方式構造
         * @param user  賬號
         * @param host	IP地址
         * @param port	埠號
         * @param privateKey  祕鑰地址(本地存放的私鑰地址)
         * @param passphrase  祕鑰密碼(如果祕鑰被加密過則需要)
         */
        public SSHInfo(String user, String host, int port, String privateKey, String passphrase) {
        	this.user = user;
        	this.host = host;
        	this.port = port;
        	this.privateKey = privateKey;
        	this.passphrase = passphrase;
        }
        
        public String getPrivateKey() {
	return privateKey;
	}
	public void setPrivateKey(String privateKey) {
	this.privateKey = privateKey;
	}
	public String getPassphrase() {
		return passphrase;
	}
	public void setPassphrase(String passphrase) {
		this.passphrase = passphrase;
	}
        public String getUser() {
            return user;
        }
        public String getPassword() {
            return password;
        }
        public String getHost() {
            return host;
        }
        public int getPort() {
            return port;
        }
    }
這個類我也提供了兩個建構函式,一個賬號密碼方式構造,一個祕鑰連線方式構造

passphrase這個欄位的意思就是當初生成祕鑰對的時候是否對祕鑰加密過,如果加密過,就需要進行解密,這個欄位就是對祕鑰加密的密碼是什麼;

這個類看了之後我們再回到之前的獲取例項方法上,那麼最後這個SSHInfo物件都被傳到SSHUtil的建構函式中,下面看一下SSHUtil的建構函式:

private SSHUtil(SSHInfo sshInfo) throws JSchException {
    	//初始化快取
    	cache = SpringContext.getBean("localCache");
    	//設定快取生命週期為1天
    	cache.set(CACHE_NAMESPACE, TimeUnit.DAYS.toSeconds(1));
    	
    	//例項化工具類的時候開啟Session通道
        jsch =new JSch();
        //祕鑰方式連線
        if (StringUtils.isNotBlank(sshInfo.getPrivateKey())) {
            if (StringUtils.isNotBlank(sshInfo.getPassphrase())) {  
            	//設定帶口令的金鑰  
                jsch.addIdentity(sshInfo.getPrivateKey(), sshInfo.getPassphrase());  
            } else {  
            	//設定不帶口令的金鑰   
                jsch.addIdentity(sshInfo.getPrivateKey());  
            }  
        }
        //獲取session連線
        session = jsch.getSession(sshInfo.getUser(),sshInfo.getHost(),sshInfo.getPort());
        //連線失敗
        if (session == null) {
        	if (StringUtils.isNotBlank(sshInfo.getPrivateKey())) {
        		logger.error("JSCH祕鑰方式開啟Session通道——失敗,伺服器賬號:{},祕鑰地址:{},祕鑰口令:{},IP地址:{},埠號:{}",sshInfo.getUser(),
        				sshInfo.getPrivateKey(), sshInfo.getPassphrase(), sshInfo.getHost(), sshInfo.getPort());
        	} else {
        		logger.error("JSCH賬號密碼方式開啟Session通道——失敗,伺服器賬號:{},祕鑰:{},IP地址:{},埠號:{}",sshInfo.getUser(),
                		sshInfo.getPassword(),sshInfo.getHost(),sshInfo.getPort());
        	}
        }
        //如果密碼方式連線  session傳入密碼
        if (StringUtils.isNotBlank(sshInfo.getPassword())) {
        	session.setPassword(sshInfo.getPassword());
        }
        session.setUserInfo(new MyUserInfo());
        //設定session通道最大開啟時間  預設5分鐘  可呼叫close()方法關閉該通道
        session.connect(SESSION_TIMEOUT);
        if (StringUtils.isNotBlank(sshInfo.getPrivateKey())) {
        	logger.info("JSCH祕鑰方式開啟Session通道——成功,伺服器賬號:{},祕鑰地址:{},祕鑰口令:{},IP地址:{},埠號:{}",sshInfo.getUser(),
    				sshInfo.getPrivateKey(), sshInfo.getPassphrase(), sshInfo.getHost(), sshInfo.getPort());
        } else {
        	 logger.info("JSCH開啟Session通道——成功,伺服器賬號:{},祕鑰:{},IP地址:{},埠號:{}",sshInfo.getUser(),
             		sshInfo.getPassword(),sshInfo.getHost(),sshInfo.getPort());
        }
    }

記住!這個方法要的私鑰地址是本地存放的地址!本地!   如果要測試的話,比如windows系統,只需要把那個生成的私鑰下載到你的電腦上,然後把路徑指向這個私鑰就行了!!!   (我就被這玩意坑了一下~~~)

那麼看到這裡大致這個Session連線就有了

我上面定義Session物件的例項的時候是沒有定義成靜態的,所以每個人呼叫這個方法的時候獲取Session是不共用的,執行緒是不共享的。

另外,在獲取Session的時候我new了一個MyUserInfo物件對吧,先來看一下這個內部類:

/*
     * 自定義UserInfo
     */
     private static class MyUserInfo implements UserInfo{
        @Override 
        public String getPassphrase() {
        	return null;
        }
        @Override 
        public String getPassword() {
        	return null;
        }
        @Override 
        public boolean promptPassword(String s) {
        	return false;
        }
        @Override 
        public boolean promptPassphrase(String s) { 
        	 return false; 
        }
        @Override
        public boolean promptYesNo(String s) {
            return true;
        }
        @Override 
        public void showMessage(String s) { }
     }
     
說實話,我當時也沒具體研究這個類的左右,我只知道在promptYesNo方法中return true;就不會在連線的時候詢問是否確定要連線,還有一種方法可以直接確認這個詢問,我這裡就不多說了;

Session連線已經有了,下面就是具體的執行方法實現了。

五、命令執行方法

話不多說,先貼程式碼:

 /**
     * 執行命令
     * @param cmd  要執行的命令
     * <ol>
     * 比如:
     * <li>ls</li>
     * <li>cd opt/</li>
     * </ol>
     * <ol>
     * 多個連續命令可用 && 連線
     * <li>cd /opt/softinstaller && chmod u+x *.sh && ./installArg.sh java</li>
     * </ol>
     * @return  成功執行返回true   連線因為錯誤異常斷開返回false
     * @throws IOException
     * @throws JSchException
     * @throws InterruptedException
     * @author wangyijie
     * @data 2017年12月7日
     */
    public boolean exec(String cmd) throws IOException, JSchException, InterruptedException {
    	logger.warn("JSCH執行系統命令:{}",cmd);
    	//開啟exec通道
        ChannelExec channelExec = (ChannelExec)session.openChannel( "exec" );
        if (channelExec == null) {
        	logger.error("JSCH開啟exec通道失敗,需要執行的系統命令:{}",cmd);
        }
        channelExec.setCommand( cmd );
        channelExec.setInputStream( null );
        channelExec.setErrStream( System.err );
        //獲取伺服器輸出流
        InputStream in = channelExec.getInputStream();
        channelExec.connect();
 
        int res = -1;
        StringBuffer buf = new StringBuffer( 1024 );
        byte[] tmp = new byte[ 1024 ];
        while ( true ) {
            while ( in.available() > 0 ) {
                int i = in.read( tmp, 0, 1024 );
                if ( i < 0 ) {
                	break;
                }
                buf.append( new String( tmp, 0, i ) );
            }
            if ( channelExec.isClosed() ) {
                res = channelExec.getExitStatus();
                break;
            }
            TimeUnit.MILLISECONDS.sleep(100);
        }
        logger.warn("系統命令:{},執行結果:{}", cmd, buf);
        //關閉通道
        channelExec.disconnect();
        if (res == IConstant.TRUE) {
        	return true;
        }
        return false;
    }
這裡用到的就是JSch的exec通道,通過之前我們獲取的session開啟這個通道,然後把命令放進去,通過getInputStream()方法,獲取一個輸入流,這個輸入流是用來讀取該命令執行後,伺服器的執行結果,比如:執行ls,那麼伺服器本身肯定會有反饋的,這裡就是把這個反饋讀出來。

我這裡只是把反饋寫在了日誌中,而返回結果只是給了呼叫成功或者失敗。

六、檔案上傳和下載方法

程式碼開路:

/**
     * 上傳檔案到伺服器(上傳傳到伺服器後的檔名與上傳的檔案同名)
     * @param uploadPath  要上傳到伺服器的路徑
     * @param filePath  本地檔案的儲存路徑
     * @param processid 唯一ID(用於檢視上傳的進度,多個地方呼叫請勿重複)
     * @param 例如:.sftpUpload("/opt", "F:\\softinstaller.zip");
     * @throws Exception
     * @author wangyijie
     * @data 2017年12月8日
     */
    public void sftpUpload(String uploadPath, String filePath, String processid){  
        Channel channel = null;  
        try {  
        	logger.warn("JSCH開啟sftp通道上傳到伺服器檔案————————上傳到伺服器位置uploadPath={},檔案所在路徑filePath={}",
        			uploadPath, filePath);
            //建立sftp通訊通道  
            channel = (Channel) session.openChannel("sftp");
            if (channel == null) {
            	logger.error("JSCH開啟sftp通道上傳到伺服器檔案失敗————————上傳到伺服器位置uploadPath={},檔案所在路徑filePath={}",
            			uploadPath, filePath);
            	return;
            }
            //指定通道存活時間
            channel.connect(CONNECT_TIMEOUT);
            ChannelSftp sftp = (ChannelSftp) channel;  
            //設定檢視進度的ID(只對該執行緒有效)
            PROCESSID = processid;
            cache.put(CACHE_NAMESPACE, processid, new HashMap<String, Long>());
            //這個物件是為了檢視進度
            Monitor monitor = new Monitor();
            //開始複製檔案
            sftp.put(filePath, uploadPath, monitor);
            logger.warn("JSCH關閉sftp通道上傳到伺服器檔案—————————上傳到伺服器位置uploadPath={},檔案所在路徑filePath={}",
        			uploadPath, filePath);
        } catch (Exception e) {  
        	 logger.warn("sftp通道上傳到伺服器檔案錯誤—————————上傳到伺服器位置uploadPath={},檔案所在路徑filePath={}",
         			uploadPath, filePath);
        }
    }
這裡檔案上傳用的是sftp,程式碼註釋應該能看懂了吧~~~~

這裡我要說明的一點就是關於檢視進度,sftp複製檔案有很多種方法,我這裡只是其中的一種,具體可百度查詢,但是想要檢視進度,就必須要涉及到一個物件Monitor ;

這個物件我下面再細說,這裡我將呼叫該方法的人傳入的唯一ID跟該檔案上傳的進度繫結在了一起。

下面直接貼上下載的方法:

 /**
     * 下載伺服器指定路徑的指定檔案
     * @param fileName   伺服器上的檔名
     * @param downloadPath	要下載的檔案在伺服器上的路徑
     * @param filePath	要存在本地的位置
     * @param processid  唯一ID(用於檢視下載的進度,多個地方呼叫請勿重複)
     * @throws Exception
     * @author wangyijie
     * @data 2017年12月11日
     */
    public void sftpDownload(String fileName, String downloadPath, String filePath, String processid) {
    	 Channel channel = null;  
         try {  
         	logger.warn("JSCH開啟sftp通道下載伺服器檔案————————檔名fileName={},下載的檔案位於伺服器位置downloadPath={},檔案下載到本地的路徑filePath={}",
         			fileName, downloadPath, filePath);
             //建立sftp通訊通道  
             channel = (Channel) session.openChannel("sftp");
             if (channel == null) {
            	 logger.error("JSCH開啟sftp通道下載伺服器檔案失敗————————檔名fileName={},下載的檔案位於伺服器位置downloadPath={},檔案下載到本地的路徑filePath={}",
              			fileName, downloadPath, filePath);
            	 return;
             }
             //指定通道存活時間
             channel.connect(CONNECT_TIMEOUT);
             ChannelSftp sftp = (ChannelSftp) channel;  
             //進入伺服器指定的資料夾  
             sftp.cd(downloadPath);  
             //該物件用於檢視進度
             Monitor monitor = new Monitor();
             sftp.get(fileName, filePath, monitor);
             logger.warn("JSCH關閉sftp通道下載伺服器檔案————————檔名fileName={},下載的檔案位於伺服器位置downloadPath={},檔案下載到本地的路徑filePath={}",
            		 fileName, downloadPath, filePath);
         } catch (Exception e) {  
        	 logger.error("sftp通道下載伺服器檔案錯誤————————檔名fileName={},下載的檔案位於伺服器位置downloadPath={},檔案下載到本地的路徑filePath={}",
            		 fileName, downloadPath, filePath);
         }
    }
    
看完這個其實跟上傳差不多,只不過一個是get,一個是put,方法底層都寫好了,我們只需要呼叫傳入引數就行了。
好了,然後就直接開始說這個進度問題吧!

七、進度檢視

先貼上程式碼:

/**
 	 * 用於檔案上傳或者下載的進度檢視
 	 * @author wangyijie
 	 * @date 2017年12月8日
 	 * @version 1.0
 	 */
    private class Monitor implements SftpProgressMonitor {
    	private long COUNT = 0;
    	/**
    	 * 檔案開始上傳執行方法
    	 */
    	@Override
    	public void init(int op, String src, String dest, long max) {
    		HashMap<String, Long> map = new HashMap<String, Long>();  //根據名稱空間和唯一ID去系統快取模組中取得快取物件  下面一樣的道理
    		if (map != null) {
    			map.put("maxsize", max); //檔案大小   單位/B
    			map.put("count", COUNT); //已經傳輸的大小(目前是0)單位/B   下面一樣的
    			map.put("isend", 0L); //是否傳輸完成   下面一樣的
    			cache.put(CACHE_NAMESPACE, PROCESSID, map);
    		}
    	}
    	/**
    	 * 檔案每傳送一個數據包執行方法
    	 */
    	@Override
    	public boolean count(long count) {
    		COUNT = COUNT + count; //沒傳輸完成一個數據包就加到已經傳輸的大小上
    	 	HashMap<String, Long> map = cache.get(CACHE_NAMESPACE, PROCESSID);
    		if (map != null) {
    			map.put("count", COUNT);
    			map.put("isend", 0L);
    			cache.put(CACHE_NAMESPACE, PROCESSID, map);
    		}
    		return true;
    	}
    	/**
    	 * 檔案傳輸完成執行方法
    	 */
    	@Override
    	public void end() {
       	HashMap<String, Long> map = cache.get(CACHE_NAMESPACE, PROCESSID);
    		if (map != null) {
    			map.put("isend", 1L);
    			cache.put(CACHE_NAMESPACE, PROCESSID, map);
    		}
    	} 
    }

這個類就是我上面說的用於檢視進度的類,當我們呼叫sftp的get、put方法時可以傳入該類,然後該類實現了SftpProgressMonitor 介面,實現了介面的三個方法,

我註釋上已經標出這三個方法的執行時間。

我在SSHUtil中定義了一個用於檢視進度的唯一ID,然後每個呼叫者獲取例項的時候,呼叫上傳或者下載方法都會給這個ID,當上傳或下載執行的時候,那麼快取中就動態的儲存了目前檔案傳輸的情況,然後我對外提供了獲取進度的方法,這樣呼叫者就可以獲取進度,也就是說檔案傳輸的執行緒來更新進度,另外的執行緒用來獲取進度。

然後我們來看一下提供的方法:

/**
     * 獲取檔案大小
     * @param processid  檔案上傳或下載方法傳入的唯一ID
     * @return
     * @author wangyijie
     * @data 2017年12月8日
     */
    public Long getFileSize (String processid) {
    	HashMap<String, Long> map = cache.get(CACHE_NAMESPACE, PROCESSID); //根據名稱空間和唯一ID獲取快取物件    下面幾個方法一樣的  把另一個執行緒put的值取出來
    	if (map == null) {
    		return null;
    	}
    	return map.get("maxsize");
    }
    
    /**
     * 獲取已經傳輸的檔案大小
     * @param processid 檔案上傳或下載方法傳入的唯一ID
     * @return
     * @author wangyijie
     * @data 2017年12月8日
     */
    public Long getCount (String processid) {
    	HashMap<String, Long> map = cache.get(CACHE_NAMESPACE, PROCESSID);
    	if (map == null) {
    		return null;
    	}
    	return map.get("count");
    }
    
    /**
     * 檔案是傳輸完成
     * @param processid 檔案上傳或下載方法傳入的唯一ID
     * @return 完成返回1 、  未完成返回0
     * @author wangyijie
     * @data 2017年12月8日
     */
    public Integer isEnd (String processid) {
    	HashMap<String, Long> map = cache.get(CACHE_NAMESPACE, PROCESSID);
    	if (map == null) {
    		return null;
    	}
		return map.get("isend").intValue();
    }
    
    /**
     * 獲取檔案傳輸的百分比
     * @param processid 檔案上傳或下載方法傳入的唯一ID
     * @param 格式  比如   :  #.## 表示精確到小數點後2位   (為空預設為小數點後2位)
     * @return  比如:  12.28%
     * @author wangyijie
     * @data 2017年12月11日
     */
    public String getPercentage(String processid, String formate) {
    	Long max = getFileSize(processid);
    	Long count = getCount(processid);
    	
    	double d = ((double)count * 100)/(double)max;
    	DecimalFormat df = null;
    	if (StringUtils.isBlank(formate)) {
    		df = new DecimalFormat("#.##");
    	} else {
    		df = new DecimalFormat(formate);
    	}
        return df.format(d) + "%";
    }

八、關閉方法

Session獲取後,記得關掉它

/**
     * 關閉session通道
     * 
     * @author wangyijie
     * @data 2017年12月8日
     */
    public void close(){
    	session.disconnect();
        logger.warn("Session通道已關閉");
    }

沒看懂的可以留言問我~~

所學尚淺,見笑~,但有所知,言無不盡,見諒!