系统功能模块设计

用户登录模块

OManager 平台的登录采用了双重安全校验机制:一种为传统的用户名与密码匹配,另一种为密钥文件校验方式,实现的原理是在密钥文件中输入任意随机字符串,通过平台自带的 md5sum.exe 工具计算出该文件的 md5,将生成的 md5 字符串更新到 users(用户表)管理员账号对应的 Privatekey 字段,以 root 用户的密钥 numbers/root.pem 为例,使用方法见图16-6和图16-7。

image 2023 12 09 18 59 29 101
Figure 1. 图16-6 查看密钥文件md5
image 2023 12 09 18 59 51 075
Figure 2. 图16-7 数据库存储的密钥文件md5数据

管理员登录时首先获得选择密钥文件的 md5,再与数据库中的 Privatekey 字段进行匹配,建议由超级管理员提前开设好所有用户的账号信息,包括用户名、密码及密钥。再统一将密钥文件以人为单位进行发放。验证的实现方法源码如下:

def Check(self, name, password, Privatekey):
    import md5
    m = md5.new(password)  # 使用md5模块计算密码的md5串
    md5pass = m.hexdigest()
    myrow = DBclass()  # 创建数据库连接对象(自定义类)
    sql = "select admin,privileges from users where admin='%s' and passwd = '%s' and Privatekey = '%s'"% (name, md5pass,Privatekey)    #参照MySQL中的用户名、
    # 密码、密钥进行校验
    result = myrow.fetchallq(sql)
    return result  # 返回结果集

下面是计算密钥文件 md5 的实现方法,主要用到了 hashlib 模块:

# 计算文件md5值,参数fileName为实体文件路径;参数excludeLine为排除的文本行;
# 参数includeLine为额外包含的行
def md5(fileName, excludeLine="", includeLine=""):
    m = hashlib.md5()  # 使用hashlib模块生成一个md5 hash对象
    try:
        fd = open(fileName, "rb")  # 打开密钥文件
    except IOError:
        print("Unable to open the file in readmode:", filename)
        return
    eachLine = fd.readline()
    while eachLine:  # 遍历密钥文件
        if excludeLine and eachLine.startswith(excludeLine):  # 排除指定的行
            continue
        m.update(eachLine)  # 用update方法对行字符串进行md5加密且不断做更新处理
        eachLine = fd.readline()
    m.update(includeLine)  # 对额外包含的行做更新和加密处理
    fd.close()
    return m.hexdigest()  # 返回十六进制结果

调用计算文件密钥 md5 方法:

md5(self.Privatekey.GetValue())  #self.Privatekey.GetValue()为用户选择的密钥文件路径

系统配置功能

OManager 平台将常用的参数配置化,包括连接数据库、主控端、传输密钥等信息,当外部环境发生变化时无须做代码变更,简单更新配置即可,提高了平台的易用性,降低使用门槛,具体是通过 ConfigParser 模块操作 ini 文件实现,效果见图16-8。

image 2023 12 09 19 04 08 147
Figure 3. 图16-8 系统配置功能

手工修改 ini 文件与界面操作达到的效果是一样的,平台 ini 文件格式如下:

data/config.ini
[system]
height = 765
width = 1024
version = v2014
upversion = 10026
ip = 192.168.1.20
port = 11511
timeout = 10
max_servers = 10
secret_key = ctmj#&8hrgow_^sj$ejt@9fzsmh_o)-=(byt5jmg=e3#foya6u
upgrade_url = http://update.domain.com/upgrade
[db]
db_ip = 192.168.1.10
db_user = servmanageruser
db_pass = 123456#abc
db_name = OManager

使用 ConfigParser 模块操作 ini 配置文件非常方便,通过 get()、set() 方法来读取与更新配置文件。读配置源码如下:

self.cf = ConfigParser.ConfigParser()  # 创建ConfigParser对象
self.cf.read(sys.path[0] + '/data/config.ini', encoding='utf8')  # 读取配置文件
# 读取“system”节中所有键值到指定的变量
self.cf = ConfigParser.ConfigParser()
self.cf.read(sys.path[0] + '/data/config.ini')
self._syswidth = self.cf.get("system", "Width")
self._sysheight = self.cf.get("system", "Height")
self._timeout = self.cf.get("system", "Timeout")
self._ip = self.cf.get("system", "IP")
self._port = self.cf.get("system", "Port")
self._max_servers = self.cf.get("system", "max_servers")
self._secret_key = self.cf.get("system", "secret_key")
self._sysversion = self.cf.get("system", "Version")
self._sysUpversion = self.cf.get("system", "Upversion")
self._upgrade_url = self.cf.get("system", "upgrade_url")
# 读取“db”节中所有键值到指定的变量
self._db_ip = self.cf.get("db", "db_ip")
self._db_user = self.cf.get("db", "db_user")
self._db_pass = self.cf.get("db", "db_pass")
self._db_name = self.cf.get("db", "db_name")

更新配置也非常简单,将 get() 方法更换成 set(),再指定 ini 的节、键、值三个元素即可。下面是更新数据库 IP 参数的代码,其中 self.DB_ip.GetValue() 为输入框的内容。

self.cf.set("db", "db_ip", self.DB_ip.GetValue())

服务器分类模块

为了让 OManager 更具通用性,平台的服务器信息依赖企业现有资产库数据,通过平台规范好的格式生成 XML 文件,结合 Tree 与 ListBox 控件实现功能分类与服务器联动,效果见图16-9。

image 2023 12 09 19 07 50 282
Figure 4. 图16-9 服务器分类选择

服务器分类的组织形式与 OMserver 保持一致,即功能分类→业务分类→服务器。图16-10为服务器类别的 XML 数据文件,用来描述服务器类别信息。

image 2023 12 09 19 08 37 060
Figure 5. 图16-10 服务器分类的XML文件

其中,“<AppClass id="1">” 标签 id 属性值为功能分类 ID 号,“<appname>应用服务器</appname>” 使用 <appname> 子元素描述功能分类名称。服务器信息的XML数据文件用来描述服务器的详细属性,详细内容见图16-11,属性与子元素说明见表16-2。

data/ServerOptioninfo.xml

image 2023 12 09 19 09 32 491
Figure 6. 图16-11 服务器信息的XML文件
image 2023 12 09 19 10 25 404
Figure 7. 表16-2 属性与子元素说明

data/Serverinfo.xml

每个管理员所负责的服务器资源通常都不一样,一般以服务器功能分类的维度划分。OManager 可为这种权限要求提供支持,实现的思路是在 users 表的 privileges(权限角色)字段定义服务器分类 ID,其中 “root” 为特殊权限,代表超级管理员,所有服务器资源都可见。账号 “demo” 的权限配置,以及在平台中展示的效果见图16-12。

image 2023 12 09 19 12 06 425
Figure 8. 图16-12 用户权限配置及展示效果

当窗体(wx.Frame)初始化时,“服务器类别” 控件会自动加载数据,实现的方法是通过遍历以上提到的两个 XML 数据,将当前账号的权限 ID 列表与服务器类别 ID 进行关联,获取所具备的权限,即拥有的服务器类别 ID。“应用名称” 则通过服务器信息的 “<option>” 元素与服务器类别 ID 进行匹配,实现源码如下:

import xml.etree.ElementTree as ET
import os
import sys

root_tree = ET.parse(sys.path[0] + '/data/ServerOptioninfo.xml')  # 打开服务器类别XML文档
class_tree = ET.parse(sys.path[0] + '/data/Serverinfo.xml')  # 打开服务器信息XML文档

root_doc = root_tree.getroot()  # 获得服务器类别XML文档root节点
class_doc = class_tree.getroot()  # 获得服务器信息XML文档root节点


class ServerClassList():
    def Resurn_list(self, UserPrivileges):  # 返回服务器类别、应用方法
        ServerList_KEY = []  # 定义返回的服务器类别、应用信息列表对象
        serverclass = []  # 定义服务器类别列表对象
        serverapp = []  # 定义业务应用列表对象

        for root_child in root_doc:  # 遍历服务器类别节点
            if not root_child.get('id') in UserPrivileges and not UserPrivileges[0] == "root":
                continue  # 没有权限的服务器类别将被忽略
            serverclass.append(root_child[0].text.encode('gbk'))  # 追加服务器类
            # 别名称<appname>
            serverapp = []
            for class_child in class_doc:  # 遍历服务器信息节点
                # 如与功能分类ID相匹配,则追加<app>到serverapp
                # 通过index()方法产生的异常判断当前<app>是否已经存在于serverapp中
                if class_child[6].text == root_child.get('id'):
                    try:
                        serverapp.index(class_child[4].text.encode('gbk'))
                    except:
                        serverapp.append(class_child[4].text.encode('gbk'))

            serverclass.append(serverapp)
            ServerList_KEY.append(serverclass)
            serverclass = []
            # 返回结果串格式:[['应用服务器',['www.a.com','www.b.com']],['数据库服务器',['www.c.com']]...]
        return ServerList_KEY

系统升级功能

相比 B/S 结构程序,C/S 结构的另一缺点是不方便升级,部分软件甚至要求重新安装、重启计算机等操作。为了解决此问题,OManager 在系统升级方面结合了B/S 的模式,将升级包放在远端,由管理员触发升级操作,同时不影响当前的其他操作,重启 OManager 程序后即可完成升级。OManager 系统升级流程图见图16-13。

image 2023 12 09 19 17 35 628
Figure 9. 图16-13 系统升级流程图

OManager 系统升级的原理:首先将升级描述文件(updateMS.xml)、升级包上传至版本服务器,由管理员触发升级操作,再通过 urllib 模块实现 HTTP 方式下载 updateMS.xml 文件并进行分析,获取所有需要升级的程序包,包括远程 URL 及下载本地存储地址,最后遍历下载所有升级包到指定的位置,完成整个升级过程。下面是升级描述文件 updateMS.xml 的示例:

<?xml version="1.0" encoding="UTF-8"?>
<wml>
    <AppClass id="1">
        <localsrc>data/Serverinfo.xml</localsrc>
        <remotesrc>/data/Serverinfo.xml</remotesrc>
    </AppClass>
    <AppClass id="2">
        <localsrc>data/ServerOptioninfo.xml</localsrc>
        <remotesrc>/data/ServerOptioninfo.xml</remotesrc>
    </AppClass>
    <AppClass id="3">
        <localsrc>OManager.10026.exe</localsrc>
        <remotesrc>/OManager.exe</remotesrc>
    </AppClass>
</wml>

在此 XML 文件中,localsrc 与 remotesrc 分别表示本地存储地址及远程 URL 路径,远程 URL 文件与本地路径建议保持一致,可以提高系统的可维护性,如本地的 “data/Serverinfo.xml” 与远程的 “/data/Serverinfo.xml”,远程升级包目录结构见图16-14。

image 2023 12 09 19 19 44 162
Figure 10. 图16-14 远程升级包存储路径

在此配置中,需要升级的程序包为 OManager.exe、Serverinfo.xml、ServerOptioninfo.xml 三个,变更项包括了添加主机信息、主程序优化等,下面介绍升级步骤。

  1. 上传升级相关文件到版本服务器指定位置,具体见图16-14。

  2. 更新数据库中平台最新版本号,即更新 upgrade 表的 version 字段,如更新版本号为 “10026”;用户会根据 data/config.ini 中的 upversion 键值与最新版本号进行匹配,当小于最新版本号时将触发升级。

  3. 点击 “系统升级” 工具栏图标进行升级,升级成功后如图16-15所示。

image 2023 12 09 19 21 21 348
Figure 11. 图16-15 系统升级成功

升级结束后,平台根目录下多了一个 “OManager.10026.exe” 最新版本的程序包,见图16-16。

image 2023 12 09 19 21 53 099
Figure 12. 图16-16 升级后的文件列表

运行 “OManager.10026.exe”,在主程序左侧的服务器列表框中多了一台 “218.31.20.11” 主机,见图16-17。再查看 data/config.ini 中的 upversion 键值,已经改为 “10026”,说明系统已成功升级。

image 2023 12 09 19 22 37 962
Figure 13. 图16-17 更新后的主机列表

介绍完系统升级的操作过程,下面介绍 OManager 实现升级功能的源码分析,使用模块 urllib.urlopen(url).read() 方法实现 HTTP 协议文件下载,使用 xml.etree.ElementTree 模块实现 XML 文件的分析。

def load_data(self, event):  # 系统升级方法
    try:
        if self.button.GetLabel() == u"关闭":
            self.Destroy()
        url = self.updateURL + "/updateMS.xml"  # 指定升级描述文件远程及本地路径
        localfile = sys.path[0] + '/tmp/updateMS.xml'
        if not self.download(url, localfile):  # 下载升级描述文件
            return
    except Exception as e:
        wx.MessageBox(u"更新描述文件下载失败" + str(e), u"OManager:", style=wx.OK | wx.ICON_ERROR)
        self.Destroy()
        return
    try:  # 打开升级描述文件,为下面的分析做好准备
        import xml.etree.ElementTree as ET
        update_tree = ET.parse(sys.path[0] + '/tmp/updateMS.xml')
        up_doc = update_tree.getroot()
    except Exception as e:
        wx.MessageBox(u"导入更新包出错", u"OManager:", style=wx.OK | wx.ICON_ERROR)
        self.Destroy()
        return
    try:  # 遍历描述文件,获取升级描述文件中所有程序包的远程及本地路径,调用
        # download()方法实现下载
        upgrade_count = 0
        for cur_child in up_doc:
            upgrade_count += 1
            url = self.updateURL + cur_child[1].text
            localfile = sys.path[0] + '/' + cur_child[0].text
            if self.download(url, localfile) == False:
                break
        self.cf.set("system", "Upversion", self.lastversion)  # 更新config.ini
        # 最新版本号
        self.cf.write(open(sys.path[0] + '/data/config.ini', "w"))
        self.ConnStaticText.SetLabel(u"成功更新" + str(upgrade_count) + "个数据包...")
        self.button.SetLabel(u"关闭")
    except Exception as e:
        wx.MessageBox(u"系统文件下载失败", "OManager", style=wx.OK | wx.ICON_ERROR)
        self.Destroy()
        return
    finally:
        pass
    event.Skip()

客户端模块编写

OManager 提供客户端模块开发支持,与 OMserver 的实现思想一样,区别是 OMserver 基于 HTML 表单来定义,而 OManager 基于 XRC。XRC(XML Resource)的设计来源于 wxWidgets,原理是将界面设计的工作从程序中独立出来,类似于 Django 开发框架中模板系统的角色,目的是将业务逻辑与界面进行分离,好处是代码的结构会更加清晰,可读性也会大大提高。具体做法是通过 XML 格式定义系统界面,当程序运行时再载入。XRC 的使用手册见 http://wiki.wxwidgets.org/Using_XML_Resources_with_XRC 。OManager 平台将功能模块采用 XRC 设计,在主程序中按功能分类导入,效果见图16-18和图16-19。

image 2023 12 09 19 25 45 890
Figure 14. 图16-18 功能模块菜单
image 2023 12 09 19 26 04 403
Figure 15. 图16-19 功能模块窗口

OManager 平台提供了最多 2 个控件参数的定义,控件类别支持 wxSpinCtrl(微调控制器)、wxListBox(列表控件)、wxTextCtrl(文本输入控件)等,当然,扩展更多的控件类型也非常简单,前提是需要了解各控件的属性及方法,其中控件值会被当成模块参数通过 rpyc 传输到服务器端。下面为 “bas_1001_系统日志.xrc” 功能模块的设计,包括一个容量控件 wxPanel 对象,wxPanel 对象包含了两个对象,一个文字标签控件 wxStaticText 对象,通过 <label> 元素定义该模块的功能文字说明;另一个对象为微调控制器 wxSpinCtrl,通过 <value> 元素定义默认值,<min> 与 <max> 定义控件的最小值及最大值。更多的控件介绍请参考: http://wiki.wxwidgets.org/Using_XML_Resources_with_XRC 。该功能模块的 XRC 定义内容如下:

Module/bas_1001_系统日志.xrc
<?xml version="1.0" encoding="utf-8"?>
<resource>
    <object class="wxPanel" name="panel">
        <size>200,100</size>
        <object class="wxStaticText" name="label1">
            <label>功能描述:显示服务器Message最新选择条数的记录。</label>
            <pos>30,20</pos>
        </object>
        <object class="wxSpinCtrl" name="Parameter1_object_id">
            <style>wxSP_ARROW_KEYS</style>
            <value>30</value>
            <min>1</min>
            <max>100</max>
            <pos>30,50</pos>
        </object>
    </object>
</resource>

在主程序中,通过 xrc.XmlResource() 方法加载 XRC 模块文件,使用 xrc.XRCCTRL() 方法获取控件对象,使用对象的 GetValue() 或 GetStringSelection() 方法得到控件输入值,其中 wxSpinCtrl、wxTextCtrl 控件使用 GetValue() 方法,wxListBox 控件使用 GetStringSelection() 方法。在主程序中调用 XRC 的方法,源码(部分)如下:

from wx import xrc

self.res = xrc.XmlResource(sys.path[0] + '/Module/bas_1001_系统日志.xrc')
# 加载模块资源文件
panel = self.res.LoadPanel(self, "panel")  # 加载panel面板控件
try:
    self.Parameter1 = xrc.XRCCTRL(panel, 'Parameter1_object_id')  # 加载控件1对象名
except Exception as e:
    pass
# 获取不同控件的返回值,GetClassName()方法返回控件类别名,用于定位不同控件获取value的方法
try:
    if self.Parameter1.GetClassName() == "wxSpinCtrl":
        self.Parameter1_value = self.Parameter1.GetValue()
    elif self.Parameter1.GetClassName() == "wxListBox":
        self.Parameter1_value = self.Parameter1.GetStringSelection()
except Exception as e:
    pass
…

平台功能模块 XRC 文件命名遵循一定的标准规范,即 “模块功能类别_模块ID_功能中文名称.xrc”,文件名将会以 “_” 作为分隔符,拆分的数据将应用到系统功能中,比如文件名前缀 “模块功能类别” 会根据不同类别代号加载到不同功能菜单,实现源码(部分)如下:

bashmenu = wx.Menu()  # 定义"基本功能"二级菜单
appmenu = wx.Menu()  # 定义"应用功能"二级菜单
dbmenu = wx.Menu()  # 定义"数据库功能"二级菜单
servicemenu = wx.Menu()  # 定义"后台服务功能"二级菜单
middlemenu = wx.Menu()  # 定义"中间件功能"二级菜单
# 根据不同XRC文件前缀,将三级菜单追加到对应的二级菜单中
for file_info in self.Moduledetail:
    file_array = string.split(file_info, '_')
    if file_info[0:3] == "bas":
        bashmenu.Append(int(file_array[1]), file_array[2], file_array[2])
    elif file_info[0:3] == "app":
        appmenu.Append(int(file_array[1]), file_array[2], file_array[2])
    elif file_info[0:3] == "dba":
        dbmenu.Append(int(file_array[1]), file_array[2], file_array[2])
    elif file_info[0:3] == "ser":
        servicemenu.Append(int(file_array[1]), file_array[2], file_array[2])
    elif file_info[0:3] == "mid":
        middlemenu.Append(int(file_array[1]), file_array[2], file_array[2])

文件 “模块ID” 段将作为该模块的唯一标识,与服务器端模块进行匹配。另外,要求模块 XRC 文件必须存放于平台 Module 目录。以下为客户端的所有模块清单,其中 ID 为 “100*” 的模块,服务器端已完成对接,其他部分读者可以根据自身的需求自行开发或扩展,平台功能模块 XRC 文件列表见图16-20。

image 2023 12 09 19 32 41 179
Figure 16. 图16-20 功能模块XRC文件列表

执行功能模块

由于 OManager 只有两层结构,与服务器端的通信就是一个交互过程,由客户端发起任务请求,服务器执行任务并返回操作结果,操作步骤见图16-21。

image 2023 12 09 19 33 26 789
Figure 17. 图16-21 功能模块执行步骤

为提高平台的通用性及兼容度,OManager 的数据封装、传输、加密方式及服务器端与 OMserver 一致,即传输采用了 rpyc 框架、RC4 加密算法、服务器端同一监听服务。服务器端的实现本节不再做介绍,具体可参考13.5.3节。下面介绍基于 wxPython 实现的客户端提交任务的几个方法。

try:
    conn = rpyc.connect(self._ip, int(self._port))  # 连接rpyc服务器
    # 调用login()方法实现通信账号、密码校验
    conn.root.login('OMuser', 'KJS23o4ij09gHF734iuhsdfhkGYSihoiwhj38u4h')
except Exception as e:
    message = u"系统提示:连接远程服务器超时。" + str(e)
    wx.MessageBox(message, u"OManager服务器管理平台:", style=wx.OK | wx.ICON_ERROR)
    return

# 调用OnGetSelectServerinfo方法获取计算机名、字符串、服务器数量
_server_list = self.OnGetSelectServerinfo('serverserial_ip', 1, int(self._max_servers))

# 判断用户是否选择了至少一台服务器,不选择则直接返回
if not _server_list:
    return

# 操作记录调用了Addsyslogs()方法写入user_logs表,用于操作记录追溯
Intologs.Addsyslogs(self.CurrentAdmin, u"操作对象:" + \
                    self.OnGetSelectServerinfo('lip', 1, 20) + u"-操作MID:" + GetModelestrrow[0])
# 合并提交串,格式:“模块ID@@主机IP*主机名,N@@参数1@@参数2@@”
# 例如:“1001 @ @ 192.168.1.21*SN2013 - 08 - 021 @ @ 30 @ @”
put_string += str(GetModelestrrow[0]) + "@@" + _server_list + "@@" + Parameter_string
# 调用tencode()方法对提交串进行加密
put_string = FunApp.tencode(put_string, self._secret_key)
# #调用rpyc的Runcommands()方法执行任务,返回的结果通过tdecode()方法解密OPresult=
FunApp.tdecode(conn.root.Runcommands(put_string), self._secret_key).decode('utf8')
# 在“输出消息”框输出返回结果
self.OnWriteMessageBox(FunApp.format_str(OPresult))
conn.close()

下面为 “输出消息” 框输出消息方法,使用 SetInsertionPoint(0) 获取消息插入点,通过 WriteText() 方法写入消息,代码如下:

def OnWriteMessageBox(self, message):
    t = time.localtime(time.time())
    st = time.strftime("%Y-%m-%d %H:%M:%S", t)  # 获取当前系统时间
    self.SysMessaegText.SetInsertionPoint(0)  # 设置消息框插入点,参数0为开始位置
    # 将方法参数message(消息内容)写入消息框
    self.SysMessaegText.WriteText("++++++++++++" + str(st) + "++++++++++++++++\n" + message + "\n")


self.SysMessaegText.SetInsertionPoint(0)

执行任务返回的结果见图16-22。另外 OManager 的窗体元素支持任意角度的组合、分离、拖动等,管理员可以根据不同喜好进行调整。

image 2023 12 09 19 38 06 107
Figure 18. 图16-22 功能模块执行结果

平台程序发布

为了让平台在没有 Python 以及第三方模块包的环境中正常运行,对源程序进行打包发布是项目最后一个环节,对此 pyinstaller( http://www.pyinstaller.org )提供了很好的解决方案,其支持 Linux 与 Windows 平台可执行程序的制作,简单易用。Pyinstaller 2.0 无须安装,解压即可使用,下面为平台打包的 bat 批处理脚本。

install.bat
cd D:\python\OManager\OManager
d:
rd /S /Q dist
rd /S /Q build
del logdict2.7.3.final.0-1.log
python d:/soft/pyinstaller-2.0/pyinstaller.py --onedir -w --icon=img/imac.ico
OManager.py
copy MD5sum.exe dist\OManager
xcopy /s data dist\OManager\data\
xcopy /s img dist\OManager\img\
xcopy /s Module dist\OManager\Module\
xcopy /s numbers dist\OManager\numbers\
xcopy /s tmp dist\OManager\tmp\
rd /S /Q build
rd /S /Q build
del logdict2.7.3.final.0-1.log

假设项目目录为 “D:\python\OManager\OManager”,参数 “--onedir” 为创建的一个目录,包含 exe 文件以及相关依赖类包;“-w” 表示制作视窗界面,无控制台(命令行);“--icon” 指定执行程序图标;“OManager.py” 为平台入口源程序。通过 xcopy 复制平台相关目录到打包路径(如 dist\OManager )。打包后的目录结构见图16-23。

image 2023 12 09 19 40 27 261
Figure 19. 图16-23 打包后生成的文件列表

最后一步就是制作安装包,我们可以简单对目录制作压缩包发布,也可以使用更加专业的安装包制作工具,如 Advanced Installer、Inno Setup、Smart Install Maker 等,最终将生成一个安装包文件 “Setup.exe”,单击安装后的效果见图16-24。

image 2023 12 09 19 41 02 282
Figure 20. 图16-24 系统安装界面