最近在做一個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通道已關閉");
}
沒看懂的可以留言問我~~
所學尚淺,見笑~,但有所知,言無不盡,見諒!