web页面管理服务以及查看日志

一、前言

​ 最近老大让我负责了一个关于部署服务的功能,旨在方便同事可以发布自己负责的服务,同时可以让没有权限对服务器进行操作的同事不再频繁的发出吼叫:“哥,我接口没通,帮我看下日志!!”。大体先介绍一下背景,因为项目是分布式集群上的,光服务就二十多个,所以每次发布服务到生产环境就成了头号难题,在和老大讨论了好久之后终于决定要啃一下这个骨头。

​ 大概的一个流程是,进入web管理页面,选择命令,后台接受之后运行linux命令。

二、演示

这个是从eureka中获取的注册的服务列表,点击详情可以查看该服务部署在了哪几台机器上,如下

可以对单个服务器上的服务进行操作,例如可以启动服务,停止服务,重启服务,更新也就是更新target,也就相当于重新发布服务,强制停止,还可以查看日志,查看日志放在最下面。

好了接下了开搞!

三、在java中运行linux命令

​ 在接收到前端页面发来的请求时,比如现在我想启动我的服务器

业务逻辑
    /**
     * 执行启动命令
     *
     * @param cmdName 命令名称
     * @param service 服务名称
     * @return
     */
    @Override
    public String execCmdOfStart(String cmdName, String service) {
        String s = "";
        try {
            //service为服务名
            service = service.split("-")[1].toLowerCase();
            logger.info("当前操作的服务名称为========>" + service);
            //此时path是我从数据库中读取的在linux中存储的shell脚本的路径
            String path = eurekaServiceMapper.getShellCmd("path");
            //getJarName()方法是用来获取指定target目录下的jar包的名称,如此一来方便运行不同的jar包
            String jarName = getJarName(path, service);
            if (StringUtils.isEmpty(jarName) && StringUtils.isBlank(jarName)) {
                return "服务启动jar包为空";
            }
            logger.info("执行服务jar包名称为:" + jarName);
            path = path + "starter.sh";//shell脚本名称
            logger.info("执行脚本的路径为=======>" + path);
            String jvm = eurekaServiceMapper.getShellCmd("jvm");
            logger.info("执行jar包内存设置为:" + jvm);

            String[] command = new String[]{path};
            String[] param = new String[]{jvm, service, jarName};//命令集合
            /*解决参数中包含空格*/
            command = (String[]) ArrayUtils.addAll(command, param);
            logger.info("要调用启动服务shell脚本为" + Arrays.toString(command));

            logger.info("解决脚本没有执行权限逻辑start");
            //解决脚本没有执行权限
            String chmod = "chmod 777 " + path;
            Process process = Runtime.getRuntime().exec(chmod);
            process.waitFor();

            logger.info("解决脚本没有执行权限逻辑end");
            
            ProcessStatus processStatus = execShell(command);
            s = processStatus.output;
            
            return s;
        } catch (Exception e) {
            logger.error("执行启动命令异常====>" + e);
            return "执行启动命令异常====>" + e;
        }
    }

上方代码只是一下简单的业务中用到的,不必深究,下面代码才是真正处理命令的

通过ProcessBuilder进行调度
/**
     * 执行shell命令(通过ProcessBuilder进行调度)
     *
     * @param command
     * @return
     */
    public ProcessStatus execShell(String[] command) {
        logger.info("execShell======>" + Arrays.toString(command));
        try {
            ProcessBuilder builder = new ProcessBuilder(command);
            builder.redirectErrorStream(true);//将getInputStream(),getErrorStream()两个流合并,自动清空流
            Process process = builder.start();

            Worker worker = new Worker(process);
            worker.start();
            ProcessStatus status = worker.getProcessStatus();

            try {
                worker.join(20000); //设置20s超时
                if (status.exitCode == ProcessStatus.CODE_STARTED){
                    //未结束
                    worker.interrupt();//中断
                    throw new InterruptedException();
                }else {
                    return status;
                }
            }catch (InterruptedException e){
                worker.interrupt();
                throw e;
            }finally {
                process.destroy();
            }
        } catch (Exception e) {
            logger.error("执行shell命令异常====>" + e);
        }
        return null;
    }

因为通过Process进行调度的时候回创建子进程,而创建的子进程是没有自己的终端或者控制器的,它的所有标准io都是通过三个流(getOutputStream()、getInputStream() 和 getErrorStream())来重定向到父进程的,父进程使用这些流来提供到子进程的输入和获得从子进程的输出。因为有些本机平台仅针对标准输入和输出流提供有限的缓冲区大小,如果读写子进程的输出流或输入流迅速出现失败,则可能导致子进程阻塞,甚至产生死锁。所以在处理标准输入流的时候同时也要处理标准错误流的。

解决死锁问题
/**
     * 设置超时
     */
    private static class Worker extends Thread{
        private final Process PROCESS;
        private ProcessStatus processStatus;

        private Worker(Process process){
            this.PROCESS = process;
            this.processStatus = new ProcessStatus();
        }

        @Override
        public void run() {
            BufferedReader bufferedReader = null;
            String line = "";
            try {
                bufferedReader = new BufferedReader(new InputStreamReader(PROCESS.getInputStream(), "UTF-8"));
                StringBuffer sb = new StringBuffer();
                while ((line = bufferedReader.readLine()) != null){
                    sb.append(line).append("<br>");
                }
                processStatus.output = sb.toString();
                processStatus.exitCode = PROCESS.waitFor();
            }catch (Exception e){
            }
        }

        public ProcessStatus getProcessStatus(){
            return this.processStatus;
        }
    }

    public static class ProcessStatus{
        public static final int CODE_STARTED =-257;
        public volatile int exitCode;
        public volatile String output;
    }

当然还有另一种调度方式即Process pro = Runtime.getRuntime().exec(new String[]{"sh",***});,但这个已经是好久的了,而且我用的时候有时候运行命令会导致死锁,有点搞不清楚,所以我用了比较简洁的 ProcessBuilder进行调度。

四、实时查看日志

这里参考的文章:https://blog.csdn.net/xiao__gui/article/details/50041673

在Linux操作系统中,经常需要查看日志文件的实时输出内容,通常会使用tail -f或者tailf命令。查看实时日志可能会需要首先SSH连上Linux主机,步骤很麻烦不说,如果是生产环境的服务器,可能还会控制各种权限。基于Web的实时日志可以解决这个问题。

由于传统的HTTP协议是请求/响应模式,而实时日志需要不定时的持续的输出,由服务器主动推送给客户端浏览器。所以这里使用的是HTML5的WebSocket协议。

准备

首先引入Spring websocket支持

        <!-- spring websocket-->
       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

实现

配置WebsocketConfig

/**
 * create by zwx on 2019/12/4
 */
@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter(){
        return new ServerEndpointExporter();
    }
}

注解@ServerEndpoint,并需要指定一个路径,用于处理客户端WebSocket请求。

/**
 * create by zwx on 2019/12/4
 */
@ServerEndpoint(value = "/log" , configurator = HttpSessionConfigurator.class)
@Component
public class LogWebSocketHandle implements BaseCommonInterFace {

    private final Logger logger = getLogger();

    private Process process;

    private InputStream inputStream;

    @Autowired
    private EurekaServiceMapper eurekaServiceMapper;

    /*解决@component下@Autowired无法取值的问题*/
    private static LogWebSocketHandle logWebSocketHandle;

    @PostConstruct
    public void init(){
        logWebSocketHandle = this;
        logWebSocketHandle.eurekaServiceMapper = this.eurekaServiceMapper;
    }

    /**
     * webSocket请求开启
     * @param session
     */
    @OnOpen
    public void onOpen(Session session, EndpointConfig config)
    {
//        HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());

        logger.info("====websocket连接建立=========");
    }

    /**
     * webSocket接收到消息
     */
    @OnMessage
    public void onMessage(String message,Session session){
        String tailLog = logWebSocketHandle.eurekaServiceMapper.getShellCmd("tailLog");
        JSONObject object = JSON.parseObject(message);
        String service = (String) object.get("service");
        service = service.split("-")[1].toLowerCase();
        tailLog=tailLog+"igs-"+service+"-log.log";
        logger.info("======日志命令====>"+tailLog);

        String[] cmdShell = new String[]{"/bin/sh","-c",tailLog};/*linux*/
//        String[] cmdShell = new String[]{"cmd","/c",tailLog};   //windows
        try {
            //执行tail命令
            process = Runtime.getRuntime().exec(cmdShell);
            inputStream = process.getInputStream();

            //开启新线程,防止InputStream阻塞处理WebSocket的线程
            TailLogThread logThread = new TailLogThread(inputStream,session);
            logThread.start();

        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * websocket请求关闭
     */
    @OnClose
    public void onClose(){
        logger.info("====websocket连接关闭=========");
        try {
            if (inputStream != null)
                inputStream.close();
        }catch (Exception e){
            e.printStackTrace();
        }
        if (process != null){
            process.destroy();
        }
    }
}

这里可能会出一个bug,即component下@Autowired无法取值的问题,已经解决了,就在上方代码中

由于针对每个WebSocket连接都会创建一个新的LogWebSocketHandle实例,所以可以不用像Servlet一样考虑线程安全问题。由于tail -f命令的输入流会阻塞当前线程,所以一定要创建一个新的线程来读取tail -f命令的返回结果:

/**
 * 创建一个新的线程来读取返回的日志
 */
public class TailLogThread extends Thread{

    private BufferedReader bufferedReader;
    private Session session;

    public TailLogThread(InputStream in, Session session){
        this.bufferedReader = new BufferedReader(new InputStreamReader(in));
        this.session = session;
    }

    @Override
    public void run() {
        String line;
        try {
//            StringBuffer sb = new StringBuffer();
//            int count = 0;
//            int lineNum = 1;
            while ((line = bufferedReader.readLine()) != null){
                /*对日志进行限流*/
//                if (count == 100){
//                    session.getBasicRemote().sendText(sb.toString());
                    Thread.sleep(40);
                    session.getBasicRemote().sendText(line+"<br/>");
//                    count = 0;
//                }else {
//                    sb.append(line).append("<br>");
//                    count++;
//                    lineNum++;
//                }
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}
web前端页面

Web前端需要通过WebSocket连接到服务端,实时接收最新的日志内容并展示到页面上。

var ws = new WebSocket('ws://'+addr+'/log');//初始化websocket对象

			//连接成功的回调
			ws.onopen = function (ev) {
				var message = {
					"addr":addr,
					"service":service
				};
				ws.send(JSON.stringify(message));
				$("#log-container div").append("连接成功!");
				console.log("连接成功!")
			};
			//连接错误的回调
			ws.onerror = function (ev) {
				$("#log-container div").append("连接发生错误!");
				console.log("连接发生错误!")
			};
			//连接关闭的回调
			ws.onclose = function (ev) {
				$("#log-container div").append("连接关闭!");
				console.log("连接关闭!")
			};

			//接受到消息的回调
			ws.onmessage = function (ev) {
				// 接收服务端的实时日志并添加到HTML页面中(error显示红色)
				if (ev.data.search("ERROR") != -1) {
					$("#log-container div").append(ev.data).css("color", "#AA0000");
				} else {
					$("#log-container div").append(ev.data).css("color", "#aaa");
				}
				// 滚动条滚动到最低部
				var scrollHeight = $("#modalBody").prop("scrollHeight");
				$("#modalBody").animate({scrollTop:scrollHeight},1);//4秒落下
			};
html
<!-- 日志.模态框 -->
    <div class="modal fade bs-example-modal-lg" id="logModal" tabindex="-1" role="dialog"  aria-hidden="true">
        <div class="modal-dialog modal-lg" style="position: absolute;top:0;bottom:0;left: 0;right:0;width: 80%;height: 80%" role="document">
            <div class="modal-content" style="position: absolute;top: 0;bottom: 0;width: 100%">
                <div class="modal-header">
                    <button type="button" class="close" style="margin-right: 15px" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
                    <h4 style="font-size: 25px">实时日志</h4>
                </div>
                <div class="modal-body" style="overflow-y: scroll;overflow-x:hidden;position: absolute;top: 80px;bottom: 20px;width: 100%" id="modalBody">
                    <div id="log-container" style="height: auto; background: #333; padding: 50px 10px 10px 10px;">
                        <div>
                        </div>
                    </div>
                </div>

            </div>
        </div>
    </div>
  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值