最近在做一个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通道已关闭");
}
没看懂的可以留言问我~~
所学尚浅,见笑~,但有所知,言无不尽,见谅!