自动化工具 基于 Python3+PyQt5 的自动替换部署工具介绍

匿名 · 2017年10月24日 · 最后由 AItestwork 回复于 2018年07月05日 · 3533 次阅读

背景

之前写过一篇文章,介绍了使用 python+paramiko 用脚本的方式,自动部署替换文件。但是脚本写出来后,在部门内并没有得到广泛应用,部门小伙伴还是倾向于手动在 Xshell 上替换部署,我表示很伤心😭 😭 😭 😭
后来一直寻思着做一个窗口工具,无意中了解了 PyQt5 这个库,后来自己折腾了一段时间,也算是搞出来第一个版本的自动部署工具,主界面如下:

大致流程分两步:
1.Qt Designer 完成界面的搭建
2.后台逻辑实现
下面详细道来。

环境搭建

1.Windows 环境电脑一台(你要问我为什么是 windows,我只能回答:因为贫穷😂
2.Python 3.4.3,关于 python 的安装这边就不说啦
3.PyQt5 安装 ,可以去官网下载安装,也可以 pip install 安装
4.paramiko 安装,直接 pip install paramiko(看到很多人装这个库遇到很多问题,可能我是幸运的,一下子就装好了,也需要了解一些 paramiko 的基本操作连接服务、执行命令、上传文件等)
5.配置 pycharm,备注(这个主要用于将 Qt Designer 设计出来的 ui 文件转换为 py 文件,主要参考了 CSDN 上一位小伙伴的文章),配置完了可以直接将设计好的 ui 文件,转换为 py 文件,非常方便

Qt Designer 的简单使用

Qt Designer 是专门用来制作 PyQt 程序中 UI 界面的工具,会生成一个后缀为.ui 的文件,可以通过命令将.ui 文件转换为.py 文件,来被其他 python 文件调用 (也可以通过配置 pycharm 来转换);
打开 PyQt5 自带的 Qt Designer,会自动弹出 “新建窗体” 对话框,如下图所示(我已经打开了一个文件)

其中:
左边区域 Widget Box,主要提供了各种控件,如:按钮、下拉单选框、文本框等,使用时可以直接将这些控件拖到中间的主窗口中,按 ‘Ctrl+R’ 可以实现预览窗口

右边区域属性编辑器,主要提供了对窗口、控件、布局的属性编辑功能,如名称、坐标位置、字体等

添加完需要的控件后,点击保存后,生成一个.ui 文件,用 notepad 打开,

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>Form</class>
 <widget class="QWidget" name="Form">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>648</width>
    <height>415</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Form</string>
  </property>
  <property name="windowIcon">
   <iconset>
    <normaloff>../22.jpg</normaloff>../22.jpg</iconset>
  </property>
  <widget class="QLabel" name="label_5">
   <property name="geometry">
    <rect>
     <x>20</x>
     <y>250</y>
     <width>81</width>
     <height>21</height>
    </rect>
   </property>
   <property name="font">
    <font>
     <pointsize>12</pointsize>
    </font>
   </property>
.
.
.
.

文件较长,这边只显示一部分

可以看到窗口大小的参数

0
0
648
415

与属性编辑器中显示的值是一致的

ui 文件转换为 py 文件

配置好的 pycharm,打开.ui 文件,选择 PyUIC,会自动将这个文件转为一个同名的.py 文件

也可以通过 python 脚本将.ui 文件转化为.py 文件,脚本如下所示:

# -*- coding: utf-8 -*-

import os 
import os.path 

# UI文件所在的路径 
dir = './'  

# 列出目录下的所有ui文件
def listUiFile(): 
    list = []
    files = os.listdir(dir)  
    for filename in files:  
        print(filename)
        if os.path.splitext(filename)[1] == '.ui':
            list.append(filename)   
    return list

# 把后缀为ui的文件改成后缀为py的文件名  
def transPyFile(filename): 
    return os.path.splitext(filename)[0] + '.py' 

# 调用系统命令把ui转换成py
def runMain():
    list = listUiFile()
    for uifile in list :
        pyfile = transPyFile(uifile)
        cmd = 'pyuic5 -o {pyfile} {uifile}'.format(pyfile=pyfile,uifile=uifile)  
        os.system(cmd)

if __name__ == "__main__":      
    runMain()

将这个脚本文件放在需要转换的文件的目录下,直接运行就可以转换。
转换的.py 脚本,如下:

from PyQt5 import QtCore, QtGui, QtWidgets

class Ui_Form(object):
    def setupUi(self, Form):
        Form.setObjectName("Form")
        Form.resize(648, 380)
        icon = QtGui.QIcon()
        icon.addPixmap(QtGui.QPixmap("../22.jpg"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
        Form.setWindowIcon(icon)

        self.label_5 = QtWidgets.QLabel(Form)
        self.label_5.setGeometry(QtCore.QRect(20, 270, 81, 21))
        font = QtGui.QFont()
        font.setPointSize(12)
        .
        .
        .
        self.retranslateUi(Form)
        self.pushButton.clicked.connect(Form.close)
        QtCore.QMetaObject.connectSlotsByName(Form)

    def retranslateUi(self, Form):
        _translate = QtCore.QCoreApplication.translate
        Form.setWindowTitle(_translate("Form", "Form"))
        self.label_5.setText(_translate("Form", "重启服务"))
       .
       .
        self.start.setText(_translate("Form", "开始部署"))
        self.pushButton.setText(_translate("Form", "关闭"))

内容较长,只显示了一部分
这个时候如果直接运行这个脚本,是不会有窗口出来的。
新建一个 CallWindow.py 文件:

#coding=utf-8
#author='Shichao-Dong'

import sys
from PyQt5.QtWidgets import *
from FirstMainWin import Ui_Form

class Main(QMainWindow,Ui_Form):
    def __init__(self,parent=None):
        super(Main,self).__init__(parent)
        self.ui = Ui_Form()
        self.ui.setupUi(self)
if __name__=="__main__":
    app = QApplication(sys.argv)
    win = Main()
    win.show()
    sys.exit(app.exec_())

运行这个 call 脚本,才会有窗口出现

其实这个就是简单的界面与逻辑分离的思想,一般将由.ui 文件转换来的.py 文件成为 界面文件,由于界面文件经常会发生变化,所以需要新建一个.py 文件调用界面文件,这个调用文件成为 逻辑文件。界面文件和逻辑文件是两个相对独立的文件,这样也就实现了界面与逻辑的分离。

内部逻辑实现

依照界面与逻辑分离的规则,所有的界面实现都集中在 FirstMainWin.py 这个文件中实现,而所有后台逻辑实现都集中在 Change.py 中实现,保证页面发生改变时不需要大改后台逻辑,同样改动后台逻辑时也不会对前台页面造成影响。
首先缕一下在 xshell 中手动部署替换文件的顺序:

  • 1.连接服务器
  • 2.cd 到文件路径,备份该文件
  • 3.上传文件
  • 4.重启服务器(非必要操作)

同样的顺序,在用这个工具的时候,也是按照连接服务 -- 备份 -- 上传文件 -- 重启的顺序;
即:在点击开始部署这个 pushbutton 时要激活上述所有步骤:
首先定义这些基础的步骤

1.连接服务

先介绍一下 QComboBox(下拉列表框) 的一些常用方法和常用信号:

  • (1)addItems 在 QComboBox 的添加一个下拉选项。
  • (2)count 返回列表项总数。
  • (3)currentIndex 当前显示的列表项序号。
  • (4)currentText 返回当前显示的文本。

常用信号:

  • (1)Activated 当用户选中一个下拉框时发射该信号
  • (2)currentIndexChanged 当下拉选项的索引发生变化时发射该信号
  • (3)highlighted
    当选中一个已经选中的下拉选项时,发射该信号

    class Main(QMainWindow,Ui_Form):
    def __init__(self,parent=None):
        super(Main,self).__init__(parent)
        self.ui = Ui_Form()
        self.ui.setupUi(self)
        self.setWindowTitle('Waiqin365-DATT-V1.0.0')
        self.password = 'xxxxxxxx'
        self.username='xxxx'
    
        self.ui.environment.activated.connect(self.printtomcat)
        self.ui.filename.editingFinished.connect(self.filename)
        self.ui.filenumber.activated.connect(self.filenumber)
        self.ui.filetype.activated.connect(self.filetype)
        self.ui.restart.activated.connect(self.needrestart)
        self.ui.start.clicked.connect(self.start)
    
    def printtomcat(self):
        '''
        根据选择的环境返回Index值,供下面connect使用
        1   231
        2   233
        '''
        print(self.ui.environment.currentText())
        tomcat = self.ui.environment.currentIndex()
        self.ui.log.setPlainText('部署的环境为:'+self.ui.environment.currentText())
        return tomcat
    
    def connect(self,Index):
        '''
        根据传入的Index连接服务器
        :param Index: 1  231  2  233
        :return: ssh
        '''
        if Index == 1:
            try:
                ssh = paramiko.SSHClient()
                ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
                ssh.connect('172.31.3.231',22,self.username, self.password)
            except Exception:
                print (Exception)
            return ssh
        elif Index == 2:
            try:
                ssh = paramiko.SSHClient()
                ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
                ssh.connect('172.31.3.233',22,self.username, self.password)
            except Exception:
                print (Exception)
            return ssh
    

替换环境这个下拉框的 object name(在设计界面的时候可以自定义)为 environment

self.ui.environment.activated.connect(self.printtomcat)

当选中一个下拉选项时,激活 printtomcat 这段代码,并返回选项的 Index,供下面连接服务器 connect 部分使用
同样的道理:
文件类型的两个下拉选项,是否重启的两个下拉选项,都是选中一个选项时,激活相应的操作

返回文件数,是单个文件还是压缩文件

self.ui.filenumber.activated.connect(self.filenumber)

def filenumber(self):
       '''
       根据选择的文件数量:单个文件还是web.zip,返回Index供下面onefile和zip使用
       1  单个文件
       2  web.zip
       '''
       print(self.ui.filenumber.currentIndex())
       filenumber = self.ui.filenumber.currentIndex()
       self.ui.log.setPlainText('文件类型为:'+self.ui.filenumber.currentText())
       return filenumber
返回文件类型是平台文件还是应用文件

self.ui.filetype.activated.connect(self.filetype)

def filetype(self):
      '''
      根据选择的文件类型:平台文件还是应用文件,自动加上前缀 /home/...web/WEB-INFO/class  还是 /home/...web/
      1  应用文件
      2  平台文件
      '''
      print(self.ui.filetype.currentIndex())
      filetype = self.ui.filetype.currentIndex()
      self.ui.log.setPlainText('文件为:'+self.ui.filetype.currentText())
      return filetype
是否需要重启

self.ui.restart.activated.connect(self.needrestart)

def needrestart(self):
    '''
    根据所选,返回Index,供下面使用是否需要重启
    1 需要
    2 不需要
    '''
    need = self.ui.restart.currentIndex()
    print(need)
    return need

2.备份替换文件并上传文件

如果文件是单个文件,根据传入的 Index 判断是平台文件还是应用文件,在返回的文件路径前自动补全地址
/home/iorder_appsvr/iorder_appsvr/web/WEB-INF/classes 或者 /home/iorder_appsvr/iorder_appsvr/web

返回文件名及路径

这边主要服务于下面上传文件的方法,因为上传文件需要 localpath,remotepath,备份文件需要 filename

def filename(self):
       '''
       返回填写的filename
       '''
       print(self.ui.filename.text())
       filename = self.ui.filename.text()
       self.ui.log.setPlainText('文件名为:'+self.ui.filename.text())
       return filename

   def localfile(self):
       '''
       返回local,即文件所在路径
       :return:
       '''
       print(self.ui.filename.text())
       filename = self.ui.filename.text()
       localfile = 'D:/file/'+ filename
       print(localfile)
       return localfile
   def path(self):
       '''
       返回填写的filepath 文件路径
       '''
       print(self.ui.filepath.toPlainText())
       filepath = self.ui.filepath.toPlainText()
       return filepath

filename 和 filepath 分别是 QLineEdit 和 QTextEdit

QLineEdit 常用方法:
  • (1)clear 清除文本内容
  • (2)setText 设置文本内容
  • (3)Text 返回文本框内容
  • (4)selectAll 全选
QTextEdit 常用方法:
  • (1)setPlainText 设置多行文本框内容
  • (2)toPlainText 返回多行文本框内容

其中 trans 主要用来上传文件,根据 Index 来连接对应的服务器
onefile 这个方法,主要用来替换单个文件,根据传入的 Index,type 连接对应的服务器,并根据类型拼接文件地址,然后执行相应的命令,备份文件,上传文件等操作
zip 这个方法,主要用来替换 web.zip,只需要一个参数 Index,连接对应的服务器即可以,也是类似的操作:上传文件,解压,复制。

def trans(self,Index,localpath,remotepath):
        '''
        根据传入的Index连接服务器并上传文件
        :param Index:
        :return:
        '''
        if Index == 1:
            try:
                trans = paramiko.Transport(('172.31.3.231',22))
                trans.connect(username=self.username,password=self.password)
            except Exception as e:
                print (e)

            sftp = paramiko.SFTPClient.from_transport(trans)
            sftp.put(localpath,remotepath)
            time.sleep(2)
            trans.close()

        elif Index == 2 :
            try:
                trans = paramiko.Transport(('172.31.3.233',22))
                trans.connect(username=self.username,password=self.password)
            except Exception as e:
                print (e)

            sftp = paramiko.SFTPClient.from_transport(trans)
            sftp.put(localpath,remotepath)
            time.sleep(2)
            trans.close()


    def onefile(self,Index,type):
        '''
        一个文件,根据传入的type判断平台文件还是应用文件,拼接完整的路径
        :param type: 1 应用文件  2  平台文件
        :param type: Index  1  连接231  2  连接233
        :return:
        '''
        if type == 1:
            remotepath = '/home/iorder_appsvr/iorder_appsvr/web/WEB-INF/classes' + str(self.path())
            self.ui.log.setPlainText('文件路径为:'+ remotepath)
            filename = self.filename()
            self.localfile()
            bak = filename + time.strftime("%y%m%d") + 'bak'
            cmd = 'cd  {0};mv {1}  {2}'.format(remotepath,self.filename(),bak)
            #备份并上传替换文件
            print(cmd)
            self.ui.log.setPlainText('备份替换文件')
            # 根据Index 连接对应的环境
            ssh = self.connect(Index)
            stdin, stdout, stderr = ssh.exec_command(cmd,get_pty=True)
            for line in stdout:
                print (line.strip('\n'))
            ssh.close()

            remotefile = '/home/iorder_appsvr/iorder_appsvr/web/WEB-INF/classes' + str(self.path())+str(self.filename())
            self.trans(Index,self.localfile(),remotefile)
            self.ui.log.setPlainText('文件上传成功')
            return cmd,filename,remotepath

        elif type == 2:
            remotepath = '/home/iorder_appsvr/iorder_appsvr/web' + str(self.path())
            self.ui.log.setPlainText('文件路径为:'+ remotepath)
            filename = self.filename()
            bak = filename + time.strftime("%y%m%d") + 'bak'
            cmd = 'cd  {0} ;mv {1}  {2}'.format(remotepath,self.filename(),bak)
            print(cmd)
            self.ui.log.setPlainText('备份替换文件')

            ssh = self.connect(Index)
            stdin, stdout, stderr = ssh.exec_command(cmd,get_pty=True)

            for line in stdout:
                print (line.strip('\n'))
            ssh.close()

            remotefile = '/home/iorder_appsvr/iorder_appsvr/web' + str(self.path())+str(self.filename())
            self.trans(Index,self.localfile(),remotefile)
            self.ui.log.setPlainText('文件上传成功')
            return cmd,filename,remotepath


    def zip(self,Index):
        '''
        web.zip包直接复制到opt后解压后复制
        Index  用来判断231 还是 233
        :return:
        '''
        cmd = 'cd /opt;rm -rf web;mkdir web;ls'
        # cd 到opt下创建新的web目录
        ssh = self.connect(Index)
        stdin, stdout, stderr = ssh.exec_command(cmd,get_pty=True)
        for line in stdout:
                print (line.strip('\n'))
        ssh.close()

        #上传文件至opt/web 并解压
        remotepath = '/opt/web/{}'.format(self.filename())
        localpath = r'D:/file/{}'.format(self.filename())
        print(remotepath,localpath)
        self.trans(Index,localpath,remotepath)
        self.ui.log.setPlainText('压缩包文件上传成功')


        cmd1 = 'cd /opt/web;ls;unzip {};rm -rf  {}'.format(self.filename(),self.filename())
        ssh = self.connect(Index)
        stdin, stdout, stderr = ssh.exec_command(cmd1,get_pty=True)
        for line in stdout:
                print (line.strip('\n'))
        time.sleep(2)
        #复制解压后的文件
        cmd2 = '\cp -Rf /opt/web/*    /home/iorder_appsvr/iorder_appsvr/'
        stdin, stdout, stderr = ssh.exec_command(cmd2,get_pty=True)
        for line in stdout:
                print (line.strip('\n'))
        ssh.close()
        self.ui.log.setPlainText('复制zip替换文件成功')

3.开始部署

上面一切准备工作就绪后,点击开始部署就 Ok 了

部署部分的逻辑如下:
根据上面 printtomcat 这个方法返回的 Index,判断是对哪台服务器进行部署
根据 filenumber 这个方法,判断是单个文件还是 web.zip,并对应的使用 onefile 这个方法还是 zip 这个方法
根据 needrestart 这个方法,判断是否需要重启

def start(self):
       '''
       开始部署  1  部署231   2  部署233
       :return:
       '''
       if self.printtomcat() == 1:
           print('部署环境为:'+ self.ui.environment.currentText())

           # 部署单个文件
           if self.filenumber() == 1:
               self.onefile(1,self.filetype())

           #部署web.zip
           elif self.filenumber() == 2:
               #连接231 部署web.zip
               self.zip(1)
               # print('222')

           #判断是否需要重启
           if self.needrestart() == 1:
               ssh = self.connect(1)
               stdin, stdout, stderr = ssh.exec_command('service  tomcat_iorder_appsvr  restart',get_pty=False)
               for line in stdout:
                   print (line.strip('\n'))
               time.sleep(5)
               ssh.close()
           elif self.needrestart() == 2:
               time.sleep(2)

       elif self.printtomcat() == 2:
           print('部署环境为:'+ self.ui.environment.currentText())

           # 部署单个文件
           if self.filenumber() == 1:
               self.onefile(2,self.filetype())

           # 部署web.zip
           elif self.filenumber()== 2:
               #连接231 部署web.zip
               self.zip(2)

           # 判断是否需要重启
           if self.needrestart() == 1:
               ssh = self.connect(2)
               stdin, stdout, stderr = ssh.exec_command('service  tomcat_iorder_appsvr  restart',get_pty=False)
               for line in stdout:
                   print (line.strip('\n'))
               time.sleep(2)
               ssh.close()
               self.ui.log.setPlainText('部署完成,服务器重启中,请稍候')
           elif self.needrestart() == 2:
               time.sleep(2)
               self.ui.log.setPlainText('部署完成')

上面就是后台逻辑实现部分。
既然这是一个窗口工具,那么如何在其他电脑上运行呢,只需要一步

打包成 exe 文件

打包生成 EXE 文件只需要两个步骤

1.安装 PyInstaller
pip install PyInstaller

安装成功后,可以在 python/Scripts 下找到

2.打包

pyinstaller [opts] xxxxx.py

可选参数有

  • F,-onefile 打包成一个 EXE 文件
  • D,-onedir,创建一个目录,包含 EXE 文件,但会依赖很多文件
  • c,-console,-nowindowed,使用控制台,五窗口
  • w,-windowed,noconsole,使用窗口,无控制台

打开命令行,进入到需要打包的.py 文件目录,运行下面的命令:

pyinstaller -F -w xxxxx.py

完成后会在同目录的 dist 文件夹中生成一个同名的.exe 文件,双击这个 exe 文件,运行效果和直接使用编辑器运行脚本效果一样;

在其他电脑上使用时,直接将 dist 目录拷贝过去即可,我这边加了 platforms 是因为在其他电脑运行报错,paltforms 文件夹来自于

C:\Python34\Lib\site-packages\PyQt5\plugins\platforms

关于 PyQt5 的学习,主要参考了《PyQt5 快速开发与实践》这本书,以及 相关文章,感兴趣的可以去看看,致谢!!!

共收到 9 条回复 时间 点赞

不错的实践,这里推荐一下 ansible

谢谢分享,的确这样部署环境舒服多了,特别是多服务器部署的时候

最近也在学习 pyqt5,那个书是实战还是实践??py3 还是 2 的?想买本看看

匿名 #4 · 2017年10月25日
佳佳 回复

pyqt5 好像只支持 3
实战...sorry 写错了

匿名 #5 · 2017年10月25日
terrychow 回复

👌,谢谢提示,有空去学习一下...

匿名 #5 · 2017年10月25日

是的,我们现在就是环境比较多,有了这个就方便很多了

我以前写个类似的 demo,不过没有用 qt,有没有 github,有时间想研究下,给你个 star😁 😁

匿名 #8 · 2017年10月27日

https://github.com/NJ-zero/PyQt5-.git
之前开会期间一直登不上去,昨天晚上才传上去😂
我这个其实也只能算一个小 demo,排版样式以及交互都没有考虑的很友好,期待你改进后的版本🤔

匿名 在 android 性能监控工具介绍 (Python+PyQt 实现) 中提及了此贴 11月23日 20:15
仅楼主可见
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册