在软件开发中,经常会遇到一些代码问题,例如逻辑结构复杂、依赖关系混乱、代码冗余、不易读懂的命名等。这些问题可能导致代码的可维护性下降,增加维护成本,同时也会影响到开发效率。
这时通常通过重构的方式,在不改变软件的功能和行为的前提下,对软件的代码进行重新组织和优化。达到增强代码的可读性,降低维护成本,提升研发效率和质量的目的。通过合理的重构,可以大大提高软件的可维护性和可扩展性,从而延长其生命。
本系列的内容介绍了百度App搜索侧业务如何使用Python/ target=_blank class=infotextkey>Python脚本实现自动化工具,以支持百度App配置数据项调用方式升级为数据通路的重构过程。通过Python脚本,我们实现一些自动化的工具,包括配置数据项调用关系分析、配置数据项接入数据通路的实现、数据项使用方接入数据通路的适配等,以期提高工作效率、减少出错率。
在代码重构过程中,需要考虑重构的效率和重构后的代码质量。与其相关的关键的步骤如下,这些步骤先后依赖,相互影响:
熟悉业务及技术现状:在开始重构之前,研发首先要理解业务逻辑和流程,熟悉业务能力及技术实现时存在的问题,确定重构的范围。
确定重构方案:基于对业务逻辑和现有代码问题的理解,确定重构方案,重点关注有两点,有问题的代码如何重构和依赖于该代码的调用如何适配。
分阶段实施:根据重构方案,分阶段的修改代码,并测试代码的功能是否正常。在修改过程中,应该尽量避免影响到不相关模块,这样可以更好地控制风险。
效果评估及监控:重构方案开发完成,线下对实现的效果进行评估,线上对实现的效果进行监控,及时发现异常止损和重构的效果。
在重构的工作中,大部分的工作是人工的方式完成,是一个耗时且容易出错的过程。对于研发人员来讲,在不改变软件的功能和行为的前提下,保证质量和效率完成对已有功能的重构,是一个极大的挑战。
为了更好的提升系统稳定性和降低配置数据项变更时对上层依赖方组件的影响, 我们决定对百度App(iOS)搜索侧的配置数据项进行重构。重构过程的关键节点中有超过80%的工作是由自动化工具完成,支持重构工作上线后零bug,和全部的配置数据项接口内敛,提升了系统的安全性和稳定性。
百度App(iOS)-搜索侧的配置数据项,大部分集中在一个类(XXXSetting)中管理。该类(XXXSetting)以独立组件的方式发布,被超过30个其它组件依赖。
如图-1 所示数据项使用模块直接调用数据项提供模块(XXXSetting),是直接依赖的关系,数据项的增删相当于接口的变更,对上层的依赖方会产生影响,当接口存在不兼容变更时,连带上层的依赖方组件也需要二次的发布。且该组件中的数据项主要为实验类开关,变动较为频繁,影响面也被放大,故需使用比较稳定的方式实现不同模块之间的数据项共享。
图-1
在技术实现的层面,主要分为两步
1、第一步为实现多模块之间数据通讯的模块,在本系列的内容中以数据通路代指该模块。
2、第二步为基于数据通路提供的能力,XXXSetting组件为作数据提供方接入数据通路,原使用XXXSetting组件的使用方接入数据通路,这样就完成了XXXSetting组件中的数据项迁移。
数据通路的实现,目标实现以Key-Value的方式读取及更新配置项,需要从无到有的构建,在本系列的其它章节中内容会有介绍。但XXXSetting组件对应的重构工作,是基于已有的线上能力的改造,Setting中的数据项超过百个,外部的调用点也是以百为计算单位,涉及的组件有30+。影响面如何评估,如何保证重构的过程质量和效果是可控的?结合对重构过程的理解,我们采用了Python脚本来支持第二步的工作。
要规避以人工方式为主的重构过程,引入错误的风险,提升重构过程的质量及效率。需要引入Python脚本实现自动化工具支持重构过程的工作。下面以重构的关键步骤,自动化工具的应用目标进行列举。
1、在熟悉业务及技术现状阶段,可以使用自动化工具对工程中现有的代码、技术架构进行分析,获取当前需要重构的代码的依赖和调用关系信息,确定重构过程的变动影响,使用自动化的方式会更加的精准。
2、在确定重构方案阶段,可以基于自动化工具产生的数据,支持重构方案的决策,包括是否需要重构,如何重构,调用方如何适配等。
3、在分阶段实施阶段,可以使用自动化的方式支持代码的重构工作,包括需要重构的模块的升级、调用方代码的适配等。对比IDE提供的查找、替换等基础工具,自动化工具可以批量处理更加复杂的重构工作。同时实施的阶段通常是繁琐且容易出错的,但使用自动化的方式可以自动完成这些任务,并减少人为错误。
4、在效果评估及监控阶段,可以使用自动化的方式对重构前后的代码进行对比测试保证功能的一致性,收集关键指标数据,发现指标的异常。
在实际的配置数据项的调用关系来看,公开的数据项可为几种情况,对应的重构方案可有不同。
1、配置数据项仅在XXXSetting模块内使用,这部分数据项不需要接入数据通路。
2、配置数据项在XXXSetting模块内使用,也在其它的模块中使用,这类数据项在XXXSetting模块中维护,数据项需要接入数据通路。
3、配置数据项在XXXSetting模块内没有使用,只在一个模块中使用,这类数据项应该迁移到使用该数据项的模块中。
4、配置数据项在XXXSetting模块内没有使用,但在一个以上模块中使用,这类数据项可以在XXXSetting模块中维护,但数据项需要接入数据通路。
基于这样的改造,XXXSetting模块的数据项接口就可以全部不公开,对于配置数据项的变更,只影响依赖配置数据项的模块。那么每个数据项的调用应该是如何重构呢,用手动查找及分析的方式成本过高,在项目实际过程评估及修改出错的概率也会增高,我们使用Python脚本实现了调用关系的分析工具,为重构工作提前进行数据支持及决策。
在分析数据项的外部调用情况之前,需要先提取XXXSetting类中所有公开的数据项。
Setting文件由OC语言开发,在Setting头文件件中公开的数据项的定义,OC类中成员变量的定义,书写方式如下
@property (nonatomic, assign) BOOL value;
@property (nonatomic, copy) NSString *value1
因头文件中,包含其它非成员变量的代码,比如include、前置声明、类定义、空代码行、注释、函数等,需要预处理下代码及使用正则表达式变量定义代码段,依次的读取.h文件中的每一行代码,以相关实现及的关键代码如下。
因代码中的注释写法存在不确定性,会对后面的正则匹配产生影响,故先把注释删除。
# 原代码行 @property (nonatomic, copy) NSString *value1; // 注释 ; * () 这些字符都有可能有,会影响后面的正则判断
newline = re.sub(r'//.+', "", line)
# 处理过后的代码行 @property (nonatomic, copy) NSString *value1;
去除注释代码之后,下一步为提取成员变量名称及类型,可以使用正则中的分组匹配的能力,提取变量类型及变量名。这里使用了正则的原因是代码的写法存在不确定性,@property的写法也会因变量类型不同而变化,故通过分组匹配的方式来实现。
# 原代码行 @property (nonatomic, copy) NSString *value1;
matchObj = re.match(r"@property.+)s+(.*)", line, re.M|re.I)
if matchObj:
# matchObj.group(1) 是成员变量类型和变量名 -- NSString *value1;
这时的代码行,因为写法的不同及变量的不同,需要进行标准化,才能提取出变量类型及变量名,主要为去除 星号(*)。代码行头中的空格已经过滤(上行代码中的s+)。
# 原代码行 NSString *value1;
newline = line.replace('*', '')
# 处理后的代码行 NSString value1;
这时代码行中只剩下类型 空格 变量名 分号,使用正则的分组匹配,提取类型及变量名。
# 原代码行 NSString value1;
# 正则表达式中s匹配任何空白字符,包括空格、制表符、换页符等等, 等价于[ fnrtv],s+代表一个或多个这类的字符
matchObj = re.match(r"(.*)s+(.*);", line, re.M|re.I)
if matchObj:
# valueType = NSString
valueType = matchObj.group(1)
# valueName = value1
valueName = matchObj.group(2)
到这了一步,公开可访问的数据项及类型的提取就已级完成,这时就可以转换代码,如果这时转换代码,会存在冗余,因为如果公开的变量在其它模块中没有使用,那实际上就不需要使用数据通路进行封装,下一步应该分析调用关系之后,再进行。
3.2 数据项关联调用组件
确定了公开的数据项之后,需要在工程源码中查找每个数据项的调用点,之后再跟据调用点数据确定每个数据项在不同的组件中调用的情况。
数据项调用代码常见于以下写法,OC中也有其它的写法,本文中以下写法作为示例介绍调用关系的生成。
[XXXSetting share].value1
# 定义个全局字典,存放每个数据项在不同的文件中调用的次数
# {数据项:{文件名:该文件内数据项调用的次数}}
valueCallInfoDic = {}
# 使用上节中,提取出来的数据项名,拼装为实际调和时的写法
realValueName = '[XXXSetting share].' + valueName
# fileNameList 为所有源码文件(.m 和 .mm)
for fileName in fileNameList:
# 记录该文件调用数据项的次数
callNum = 0
# 记录文件每个文件调用该数据项的次数信息
fileCallInfoDic = {}
# 依次的读取源文件的每一行,匹配调用情况,记录调用次数,及文件名,line 为代码行
for line in f:
# 使用正则全字匹配,查找替换
regAbKey = realValueName.replace('[', '[')
regAbKey = regAbKey.replace(']', ']')
regAbKey = regAbKey.replace('.', '.')
# pattern = [XXXSetting share].value1b 主要为了防止数据项名有子串的情况
pattern = r'' + fromstr + r'b'
matchObj = re.match(r'.*' + regAbKey +'', line, re.M|re.I)
if matchObj:
callNum = callNum + 1
if callNum > 0
fileCallInfoDic[fileName] = str(callNum)
# 如果有调用关系,则存储
if len(fileCallInfoDic)
valueCallInfoDic[valueName] = fileCallInfoDic
使用Python分析的数据还是以机器语言的形式表式,需要以人类语言描述,将数据输出为excel表格,这样就可以借助于表格工具进行数据的查看及分析。
表格的输出Python没有使用有excel操作的相关库,使用 ,(逗号)作为分隔符,存储为.csv文件,在excel中导入csv文件使用。
具体的实现为依次的将每个数据项的使用的组件,使用的文件及在这个文件文件中使用次数,输出到.csv文件中。
# 表头分别为,数据项,使用的组件,使用的文件,文件中使用次数
outfiledata = 'value , uselib , usefile , usenumn'
# 遍历全局字典valueCallInfoDic,获取每个数据项 及数据项的调用信息
# {数据项:{文件名:该文件内数据项调用的次数}}
for.valueName , valueInfo in valueCallInfoDic.items():
# 从数据项的调用信息中获取,文件名和该文件内数据项调用的次数
# {文件名:该文件内数据项调用的次数}
for.fileName , callNum in valueCallInfoDic.items():
outfiledata += valueName + " , "
# libByFile 函数,实现根据文件获取所在的组件名
outfiledata += libByFile(fileName) + " , "
outfiledata += fileName + " , "
outfiledata += callNum + " n"
基于输出的表格数据,可以比较容易的判断每个数据项的优化影响范围,下表为表格数据的示例。
△注:表格数据非真实业务场景数据
基于数据的调用关系数据,确定每个数据项被每个组件使用的情况,并确定重构的方式。
同样,表格的输出Python没有使用有excel操作的相关库,使用 ,(逗号)作为分隔符,存储为.csv文件,在excel中导入csv文件使用。
具体的实现为依次的读取数据项,计算每个数据项被组件的使用情况,并将结果输出到.csv文件中。
# 表头分别为 ,数据项 ,使用的组件 ,组件中总使用次数 , 使用类型
outfiledata = 'value , uselib , usenum , usetype n'
# 遍历全局字典,获取每个数据项 及数据项的调用信息
# {数据项:{文件名:该文件内数据项调用的次数}}
for.valueName , valueInfo in valueCallInfoDic.items():
libCallInfo = {}
# 从数据项的调用信息中获取,文件名和该文件内数据项调用的次数
# {文件名:该文件内数据项调用的次数}
for.fileName , callNum in valueCallInfoDic.items():
# libByFile 函数,实现根据文件获取所在的组件名
libName = libByFile(fileName)
if libName in libCallInfo:
libCallInfo[libName] = int(libCallInfo[libName]) + int(callNum)
else:
libCallInfo[libName] = callNum
# 每个组件的使用XXXSetting 的数据项情况
hasSelfCall = False
useType = ""
for.libName in libCallInfo:
if libName == "XXXSetting":
hasSelfCall = True
break
if len(libCallInfo) == 1:
if hasSelfCall:
# 配置数据项仅在XXXSetting模块内使用,这部分数据项不需要接入数据通路。
useType = "selfCall"
else:
# 配置数据项在XXXSetting模块内没有使用,只在一个模块中使用,这类数据项应该迁移到使用该数据项的模块中。
useType = "otherCall"
else:
if hasSelfCall:
# 配置数据项在XXXSetting模块内使用,也在其它的模块中使用,这类数据项在XXXSetting模块中维护,数据项需要接入数据通路。
useType = "selfAndOtherCall"
else:
# 配置数据项在XXXSetting模块内没有使用,但在一个以上模块中使用,这类数据项可以在XXXSetting模块中维护,但数据项需要接入数据通路。
useType = "othersCall"
for.libName , libCallNum in libCallInfo.items():
outfiledata += valueName + " , "
outfiledata += libName + " , "
outfiledata += libCallNum + " n"
基于输出的表格数据,可以比较容易的判断每个数据项应该如何整改,下表为表格数据的示例。
注:表格数据非真实业务场景数据
以上的内容,介绍了代码重构过程的工作及挑战,同时以Python脚本实现分析模块的调用关系的统计,基于该脚本,在重构工作开始之前,可以精确统计每个XXXSetting类对外公开的类成员属性,被其它组件使用的情况。基于统计的数据,可以感知对应的每个成员属性在App中的使用情况,且可容易的评估XXXSetting数据项重构升级为数据通路工作所带来的影响。
当这部分工作,使用人工的方式实现,依次查找每个成员属性的在App中的使用情况及分类记录,是一件重复性高,出错概率高的工作。而使用自动化工具,很好的规避了这些问题,且长期可积累。