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