配置文件(JSON格式)替换工具

1.问题背景

为了支持灵活的配置:配置项可随时按需增减,我们应用的配置文件采用了JSON格式的配置文件。其整体是一个JSON对象,内部按照key分设各个服务:前置、核心、入库、校验等的子JSON对象,子JSON对象内部按照服务自身需要配置:可以是JSON Object、也可以是JSON Array。大致如下:

# 注释行,将被忽略
####################################################################
# 配置文件样例
####################################################################
{
    "completion_to_fileCore_dir": "/completion_to_fileCore/#{busiLine}/#{settleDate}", 
    "redis_key_expire_time": "100", 
    ... ....
    "CsvFileFrontToCoreService": [ ... ],
    "CsvFileCoreToFrontService": [ ... ],
    "CsvFileCoreService": {
        "CsvFileCoreToValidatorService": [ ... ],
        "CsvFileExportToCoreService": [ ... ],
    },
    "ClearingRedisService": { ... },
    ... ....
}

JSON的灵活性极大方便了配置的修改,且堆应用程序编写友好。比如:

  • 对于1个或多个的配置项,直接使用JSON Array,应用程序直接遍历处理。
  • 对配置项某些情景有、某些情景无,应用程序直接检查JSON Object是否含有这个配置key,据此按有无配置项的策略处理即可,比如设默认值等等。

2.问题生产

方便开发的情况下,对运维和测试的修改就不是很友好,主要表现在:

  • 不熟悉配置文件情况下需要整个配置文件找待修改配置项
  • JSON的逗号、反括弧等容易修改错误

这就加剧了人工修改导致错误的可能,切效率低下。在生产、开发、测试环境差异巨大且各方没有建设集中配置环境中心的情况下,寻找自动化替换配置、避免低效且易错的手工修改配置方案就显得很重要。

3.基于INI文件的JSON格式配置文件自动化替换

3.1 思路

受爬虫解析HTML使用xpath的启发,可以把JSON对象也抽象为这种path形式。下面以一个JSON实例说明:

{
    "commonProduct":{
        "name":"普通商品汇总",
        "productList":[
            {
                "productId":"commonProduct_001",
                "productName":"矿泉水",
                "productPrice":"2.00"
            },
            {
                "productId":"commonProduct_002",
                "productName":"冰可乐",
                "productPrice":"3.50"
            }
        ]
    },
    "specialityProduct":{
        "name":"特色商品汇总",
        "productList":[
            {
                "productId":"pecialityProduct_001",
                "productName":"椰子糖",
                "productPrice":"30.00"
            },
            {
                "productId":"pecialityProduct_002",
                "productName":"芒果干",
                "productPrice":"35.00"
            }
        ]
    }
}

各个属性可以按这样的定义:

  • JSON对象:
commonProduct.name:代表取值“commonProduct”这个JSON 对象的“name”属性
specialityProduct.productList:代表取值“specialityProduct”这个JSON 对象的“productList”属性
  • JSON数组:
commonProduct.productList#0:代表取值“commonProduct”这个JSON 对象的“productList”这个JSON Array的第一个属性,即"矿泉水"那个属性
specialityProduct.productList#1.productName.:同理,代表取值“specialityProduct”这个JSON 对象的“productList”属性,即JSON Array第2个的"productName"属性,取值为"芒果干"

定义好每个属性的获取规则,替换过程就比较容易,思路是:

  • 解析INI文件
  • 读取JSON文件
  • 按照INI文件的path逐个属性进行替换
  • 替换后的JSON写入文件

至于开发语言选择熟悉的Python,只需使用基础库就能完成这个功能,不必考虑生产环境包安装等其它问题。

3.2 解析INI文件

解析INI文件是python自带的包ConfigParser就足够了,为了方便操作,将操作方法封装到一个类中,代码如下:

import ConfigParser

class ConfigParserWithoutChangeOpions(ConfigParser.ConfigParser):
    '''
    继承ConfigParser.ConfigParser,重写optionxform()方法,无需将option的值改为小写
    '''
    def __init__(self,defaults=None):
        ConfigParser.ConfigParser.__init__(self,defaults=None)
        
    def optionxform(self, optionstr):
        '''
        复写父类optionxform方法,不改变key值大小
        '''
        return optionstr

class IniConfigParser(object):
    '''
    INI文件解析
    '''
    def __init__(self, path):
        '''
        初始化方法
        '''
        self.path = path
        # self.conf = ConfigParser.ConfigParser()
        self.conf = ConfigParserWithoutChangeOpions()
        self.conf.read(path)    

    def get_sections(self):
        '''
        获取所有的section节点
        '''
        return self.conf.sections() 

    def get_section_options(self,section):
        '''
        获取section节点的所有key
        '''
        return self.conf.options(section)

    def get_section_items(self,section):
        '''
        获取section节点的所有属性
        '''
        return self.conf.items(section)

    def get_section_item(self, section, option):
        '''
        获取section节点的指定属性
        '''
        return self.conf.get(section, option)

    def exist_section(self, section):
        '''
        检查section是否存在
        '''
        return self.conf.has_section(section)

    def exist_option(self, section, option):
        '''
        检查option是否存在
        '''
        return self.conf.has_option(section, option)

只是简单封装了下提供额外的获取INI文件内部配置块的一些方法,唯一特别的是继承了ConfigParser,重写了其optionxform()方法。
在源码:/usr/lib64/python2.7/ConfigParser.pyConfigParser类中找到optionxform()方法实现:

def optionxform(self, optionstr):
        return optionstr.lower()

源码中将INI的key全部转为小写,这意味着如果配置:notifyUrl=http://test.url,那默认的ConfigParser解析后会变成:'notifyurl=http://test.url',这回严重影响后续JSON替换,因为JSON的key存在大小写区分,不能随意更改key的大小写,这回影响应用程序解析配置。
通过继承ConfigParser.ConfigParser类重写optionxform()方法,不会将optionstr转为小写避开这个问题。

3.3 JSON文件读写

python自带json模块能够很容易将json字符串转为dict对象,随意修改后再写回文件存储。不过,实际配置文件格式和内容提出了一些额外的要求:

  • 读文件转为字典对象,要求能够区分原始文件#号开头的注释和实际配置内容
  • 读取配置内容(JSON对象)必须能够与原始配置文件顺序一致,否则写入文件后顺序会与读取顺序不一致

为此,封装了更加适合配置替换修改的类:

import json
import collections   

class JsonConfigParser(object):
    '''
    JSON文件读取解析
    '''
    def __init__(self):
        '''
        初始化方法
        '''
        self.comments = ''
        
    def read(self, path):
        '''
        读取json
        '''
        self.path = path
        conf = ''
        with open(path, 'rb') as f:
            for line in f.readlines():
                # 剔除json文件已#开头的注释行
                if(not line.startswith('#')):
                    conf += line
                else:
                    self.comments += line
        # 解析成json:使用collections.OrderedDict保证读取顺序和文件顺序一致
        conf_json = json.loads(conf, object_pairs_hook=collections.OrderedDict)
        # 返回配置json
        return conf_json
        
    def write(self, path, conf):
        '''
        写入json
        '''
        # 如果路径不存在,尝试创建父路径
        print conf
        dir = path[0:path.rfind('/')]
        if(not os.path.exists(dir)):
            print(u'路径:%s不存在, 开始创建...' % dir)
            os.makedirs(dir)
        # 写入文件
        with open(path, "wb") as f:
            # 先写入注释
            f.write(self.comments)
            # indent默认为None,小于0为零个空格,格式化保存字典;默认ensure_ascii = True,即非ASCII字符被转化为`\uXXXX`
            f.write(json.dumps(conf, indent=4, ensure_ascii=False)) 
            # f.write(unicode(json.dumps(conf, indent=4), "utf-8")) 
        print(u'修改后配置写入文件:%s完成!' % path)

简单说明下:

  • 读取配置文件方法时,会检查每行行首是否为#号(文件都是utf-8编码),如果是将它们作为注释暂存起来
  • 对读取到的JSON字符串内容,使用json.loads(conf, object_pairs_hook=collections.OrderedDict)确保读取转换后的dict顺序与文件内容一致的,collections包的OrderedDict能够确保这样的顺序,由此确保后续修改完成写入文件时还能保持原本配置文件的顺序
  • 读写文件的文件路径参数都是全路径,写文件时限检测父目录是否存在,不存在会先创建目录(没有做IO异常之类判断,还不是因为懒...)
  • 写文件时先把读取时缓存的注释写入文件,再写入修改好的dict对象,利用json.dumps(conf, indent=4, ensure_ascii=False)有2点考虑:
    • indent=4使得父子两级之间缩进增加4个空格位
    • ensure_ascii=False使得非ASCII能够不被转义成Unicode写入文件,比如中文,否则像中文“中国”写入文件会变成“\u4e2d\u56fd”

4.修改功能实现

如前所述,修改功能就是遍历INI文件指定section的配置项,其key为类似xpath的表述形式,按它找到读取的JSON文件找到带替换的key,将值替换掉。

class ContentModifier(object):
    '''
    配置内容修改器,依据配置项的key-val对,进行配置文件的修改
    '''
    def __init__(self, conf_paraser):
        '''
        初始化方法
        '''
        self.conf_paraser = conf_paraser       
        
    def json_replace_conf(self, conf, key, val):
        '''
        对json的指定key替换值
        ''' 
        if(not conf.has_key(key)):
            print(u'未找到key为:%s的选项,原始值为:%s' % (key, conf))
        # 替换值
        conf[key] = val
        # 返回
        return conf
                
    def json_replace_recursive(self, conf, key_pattern, val):
        '''
        按照key_pattern递归到最后一层,将其值修改为传入的val
        以CsvFileExportToCoreService#0.exportRules#0.fileExportRules.rule为例,表示:
            待修改的值在一级keyCsvFileExportToCoreService的值中,且它是array,#0指明要修改的在array的第一个
            待修改的值在第一个array的key为exportRules中,这个exportRules的值也是array,#0需要修改的指明要修改的在array的第一个
            待修改的值在第一个array的fileExportRules指定值中,此为json对象
            待修改的值在json对象的rule中
        '''
        print '-------%s : %s' % (key_pattern, val)
        if(len(key_pattern.split('.')) == 1):
            if(not '#' in key_pattern):
                return self.json_replace_conf(conf, key_pattern, val)     
            else:
               real_key = key_pattern.split('#')[0]
               index = key_pattern.split('#')[1]
               conf_arrary = conf[real_key]
               # print conf_arrary
               conf_arrary[int(index)] = val
               conf[real_key] = conf_arrary
               return conf
        else:
            key = key_pattern.split('.')[0]
            if '#' in key:
                # 剔除#index拿到key
                real_key = key.split('#')[0]
                # 从#index拿到array的index
                index = key.split('#')[1]
                # 先取的array,在从array中按照index取出需要的
                conf_arrary = conf[real_key]
                real_conf = conf_arrary[int(index)]
                # 对待替换的配置继续递归处理
                # print '========== ' + key_pattern[key_pattern.index('.')+1:]
                replaced_conf = self.json_replace_recursive(real_conf, key_pattern[key_pattern.index('.')+1:], val)
                # 修改好的值替换掉原本的这个index的array中的值
                conf_arrary[int(index)] = replaced_conf
                # 再将这个array赋值回原本json的这个key的部分,达到改变配置效果
                conf[real_key] = conf_arrary
                # 返回调用者的是对原始json替换后的
                return conf
            else:
                # 不是array类型,直接取出值进行递归替换
                # print '========== ' + key_pattern[key_pattern.index('.')+1:]
                replaced_conf = self.json_replace_recursive(conf[key], key_pattern[key_pattern.index('.')+1:], val)
                # 修改好的json替换原始json
                conf[key] = replaced_conf
                # 返回替换后的原始json
                return conf
            
    def json_modify(self, section, content):
        '''
        按照配置conf,取出其section段配置,对content进行修改
        '''
        #print content
        replaced_json = content
        if(not self.conf_paraser.exist_section(section)):
            raise RuntimeError(u'配置文件:%s没有section名为:%s的配置' % (self.conf_paraser.path, section))
        else:
            items = self.conf_paraser.get_section_items(section)
            # 替换所有需要的项
            for item in items:
                print '%s : %s' % (item[0], item[1])
                replaced_json = self.json_replace_recursive(replaced_json, item[0], item[1])
        # 返回修改好的配置json
        return replaced_json

json_modify(self, section, content)是供外部调用方法,参数section指定INI文件的配置块,参数content需要替换的JSON/dict对象。
核心实现是json_replace_recursive(self, conf, key_pattern, val)方法,原理大致如下:

  • 检测key_pattern没有"."分隔符,则有2种可能:
    • 含有"#"分隔符,说明该JSON key的值为一个JSON Array,#号后的数字就是需要替换的JSON Array编号
    • 不含有"#"分隔符,说明其值就是需要替换的,直接将传入val替换原本的值
  • 检测key_pattern含有"."分隔符,取key_pattern按照"."分隔的第一个值,判断其是否含有"#"也有2种可能:
    • 若无,说明它的子JSON对象才是需要替换的,递归处理,递归参数:子JSON对象和key_pattern第一个"."之后的字符串为子key
    • 如有,说明待替换的目标在key_pattern按照"."分隔的第一个值除去#index指向的JSON Array中,具体是哪个Array由#好后面的index指定

为了方便使用,使用命令行参数的方式实现使用:

 
#  主逻辑
if __name__ == '__main__': 
    tips = u'使用方法:%s conf.ini 0091-account.config' % sys.argv[0]
    print "\r\n┌─" + (len(tips) - 4)*"-" + "─┐"
    print "│"+2*" " + tips +1*" " +"│"
    print "└─" + (len(tips) - 4)*"-" + "─┘\r\n"    
    
    # 参数校验
    print sys.argv
    if(len(sys.argv) != 3):
        print u'参数个数错误,请检查!使用例子:%s' % tips
        print ""
        exit(1)
        
    
    # 装配目录:默认都是当前文件夹下
    ini_path = os.path.join(os.getcwd(),sys.argv[1])
    conf_path = os.path.join(os.getcwd(),sys.argv[2])
    # 初始化解析器
    paraser = IniConfigParser(ini_path)
    # 初始化json解析器,并读取json配置文件
    jsonparaser = JsonConfigParser()
    jsondata  = jsonparaser.read(conf_path)
    #初始化修改器
    modifier = ContentModifier(paraser)
    # 按照指定ini文件section部分进行替换
    replaced_json = modifier.json_modify(sys.argv[2], jsondata)
    # 替换后结果写入文件
    jsonparaser.write(os.path.join(os.getcwd(),'reslut',sys.argv[2]), replaced_json)
    

5.使用例子

假设我们有如下的JSON格式文件data.config,其内容如下:

# 注释行,将被忽略
####################################################################
##   注释
####################################################################
{
    "commonProduct":{
        "name":"普通商品汇总",
        "productList":[
            {
                "productId":"commonProduct_001",
                "productName":"矿泉水",
                "productPrice":"2.00"
            },
            {
                "productId":"commonProduct_002",
                "productName":"冰可乐",
                "productPrice":"3.50"
            }
        ]
    },
    "specialityProduct":{
        "name":"特色商品汇总",
        "productList":[
            {
                "productId":"specialityProduct_001",
                "productName":"椰子糖",
                "productPrice":"30.00"
            },
            {
                "productId":"specialityProduct_002",
                "productName":"芒果干",
                "productPrice":"35.00"
            }
        ]
    }
}

假设需要进行修改如下:

  • 矿泉水价格改为3.00
  • 冰可乐价格改为2.50
  • 椰子糖的productId改为modifiedSpecialityProduct_001
    -椰子糖名称改为椰子糖(无糖型)

那么需要编制如下的配置INI文件:

[data.config]
;price
commonProduct.productList#0.productPrice=3.00
commonProduct.productList#1.productPrice=2.50

;id
specialityProduct.productList#0.productId=modifiedSpecialityProduct_001
;name
specialityProduct.productList#0.productName=椰子糖(无糖型)

注意中括弧中section名称为data.config,与需要修改的JSON文件保持一致。
执行如下命令:

./modify.py conf.ini data.config
20190517181601.jpg

可以得到最终结果:


20190517181733.png

所有指定的价格、名称均已替换完成,达到了预期的效果。

6.总结

暂时不支持JSON内容的增加和减少,比如一个Array含有3个元素,其实只需要1个元素,那只会让1个元素替换修改,其它Array元素无法处理,后续有空再处理吧(还不是因为懒~)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351