0%

如何写出更好的程序一:用好配置文件和减少硬编码

职场新人兼新手程序员斗胆开了新坑「如何写出更好的程序」,所见所得都是来自实际写代码时自己的思考,且已脱敏。这一系列不包含任何复杂的技术,也不包含任何难懂的代码。只是将核心问题暴露出来,针对这些场景,如何写出可维护性更高、更简洁优雅的代码。

python 为例,本文的主要内容包括:如何使用配置文件,以及如何减少代码中的硬编码,引申到了代码的组织架构和可维护性上。

如何使用好配置文件

针对一个代码文件使用配置文件的情况

假设只有在 main.py 中需要读取配置文件,将配置文件的部分变量以传参的形式交给其他函数使用,这是最简单的场景。举个简单的例子,如果是生产环境,那么 env=debug;如果是开发环境,那么 env=release,当然这是从配置文件里读取得到的。考虑复杂一些的情况,如果是用户 DIY 使用,可能需要的变量并不在配置文件中。

对于这一场景,建议将配置文件写到 config.py 中,并且用一个类进行封装,变量就是类的成员。当需要根据生产或开发环境执行不同的代码时,只需要在类内进行判断即可。当用户需要增加其他变量时,由用户继承这一个类并添加自己的变量和方法就好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Data:
def __init__(self):
self.env1 = ...
self.env2 = ...
self.__setup()

def __setup(self):
if self.env1 == "1":
func1()
else:
func3()

if self.env2 == "2":
func2()
else:
func4()

针对多个文件使用配置文件的情况

如果此时有几十个代码文件都需要读取配置文件,获取其中的变量并执行对应的代码,总不能每个文件都创建一个类对象并初始化吧。你说参数传递?如果函数的传参很困难又该怎么办呢?具体而言,当开发后端的时候,main.py 读取配置文件并得到了 env=debug,此时打开了网页,点击一些按钮完成一些交互,则 web 端会通过 js 发起了一个 post 请求,告诉你需要执行某些代码,这个请求被 handler.py 拦截到。

此时存在一个问题:handler.py 中的 get 方法拦截到 web 端请求,并不是 main.py 直接将请求发送到 handler.py。所以此时不能直接传递参数,handler.py 也并不知道 env=debug,所以可能不知道执行哪些代码。再去重新实例化一个类?几十个代码文件都去实例化同一个类,未免浪费空间。

简单的参数可以加到 post 请求的 url 里,但是当参数高达十几个时,传参和接收参数这会很麻烦。何况配置文件就在那里,handler.py 直接获取会方便很多。这个时候建议将配置文件写到 config.py 中,但不是以类的形式,而是直接写入变量并赋值,如 ENV="DEBUG"。当任何文件需要读取这一变量时,直接 import config; config.ENV 便可获取。有点类似 C 语言中的 #define

yaml 或者 json?

还有一些通过读取 yamljson 等配置文件来生成变量的,但是这会不可避免的增加代码中的硬编码,而且只能获取变量。根据变量去判断执行哪些方法需要单独实现,所以没有考虑使用。具体而言:

  • 对于情景一中的代码,用类实现配置文件的话可以直接调用类内的 __setup() 方法。如果是 yaml 文件,从文件加载到 env1, env2 后,需要单独去写情景一例子中的 __setup() 方法,不如封装到类内方便。

  • 对于情景二,如果几十个代码文件都去执行 import yaml; yaml.load() 来获取配置文件中的变量,这又会造成大量的文件 IO,没有意义。这也是我不考虑使用 yaml,json 作为配置文件的原因。

减少代码的硬编码

在有了配置文件后,可以有效减少代码中的硬编码,增强代码的可维护性。比如创建了一个字典:

1
2
3
data["name"]     = ...
data["value"] = ...
data["children"] = ...

但是此时后台的接口忽然发生了变化,children 这个名字忽然改成了 subfunc,后台解析只认 data["subfunc"] 这个字段,上面的写法需要去所有代码文件里一个个的搜索 "children" 并替换为 "subfunc",显然是很累又不得不干的活。这个时候可以使用配置文件:

1
2
3
4
5
6
config.py
CHILDREN = "children"

main.py
import config
data[config.CHILDREN] = ...

如果再遇到 children 名字改成了 subfunc,只需要在 config.py 里修改 CHILDREN 的取值就可以了,只需要修改一次,比上面的实现优雅一些。

重灾区:函数返回值

另一个硬编码重灾区是函数的返回值,众所周知 python 函数是可以有多个返回值的,对于暂时不需要的返回值可以用下划线忽略掉。

1
2
3
4
def func():
return name, info, value, key, address, flag, context

name, info, value, key, address, _, context = func()

其实上面获取函数返回值的形式更像列表的切片:

1
2
3
4
5
6
7
8
9
10
def func():
return name, info, value, key, address, flag, context

return_val = func()
name = return_val[0]
info = return_val[1]
value = return_val[2]
key = return_val[3]
address = return_val[4]
context = return_val[6]

可以看到,如果要调用 func 函数,就必须牢记返回值的顺序,当代码文件很多时并不友好,也不优雅。当需要增加或减少返回值的数量时,切片访问函数返回值的形式也很难处理。比如当不需要返回 name 字段时,或者需要增加一个 param 参数,下标都需要修改。增加返回值时, 别说把这个返回值放到所有函数返回值的最后,这只是为了代码能运行起来做的妥协,没意思。以上情况对于调用 func 的函数而言都需要一个个手动修改,简直是一场灾难。

这个时候建议使用类对象或者字典,道理是一样的:

1
2
3
4
5
def func():
return {
"name": name,
"info": info
}

这样,就在也不需要记住返回值的顺序,也不必担心函数增加或减少返回值,甚至不用关注返回值的顺序。都可以直接通过字典的 key 访问。你说 "name", "info" 这样的硬编码不好?可以用前面讲述的配置文件避免掉它呀。

C 这种语言并不支持函数返回多个变量,需要返回多个变量时都是使用结构体来完成,这种想法值得借鉴。对于 python 语言,字典也好,类对象也罢(对象的话就是通过成员访问),取决于具体的适用场景,但是都可以避免通过切片这样的硬编码方式去获取函数的返回值。

使用类规范函数返回值

对于一个函数,接受原生的数据 raw_data 完成解析,并返回各种信息数据:

1
2
3
def func(raw_data):
...
return info1, info2, info3, info4, info5, info6

但是其他函数使用返回值时,info1到info6这些信息并不是全部都需要使用。有时候仅仅需要使用 info1info4,很烂的写法有两种:

1
2
3
4
5
6
7
1. 
info1, _, _, info4, _, _ = func(raw_data)

2.
data = func(raw_data)
info1 = data[0]
info4 = data[3]

上述写法,当 func 函数发生变化,如:增加其他返回值、删除无用的返回值时,对于代码维护而言都是一场灾难。千万不要假设需求不会变化,也不要假设针对接口编程时接口始终不变,永远不知道会面临什么新的鬼需求和变动。就算是针对接口编程,每个函数的返回值是什么,返回值的顺序都需要记住,是一种很累的事情。

除了上文讲述的使用字典或者类之外,还有一种其他方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Info:
def __init__(self):
self.__idx = {
"info1" : 0,
"info2" : 1,
"info3" : 2,
"info4" : 3,
"info5" : 4,
"info6" : 5,
}

def get_item(self, data, args):
return_val = []
for i in args:
return_val.append(data[self.__idx[i]])
if len(return_val) == 1:
return return_val[0]
return return_val

info = Info()
info1, info2 = info.get_item(func(raw_data), ["info1", "info2"])

只需要创建一个对象,在 get_item 这个函数的参数中指定自己想要获取的参数和顺序即可。即使函数 func 的返回值发生了顺序、数量等方面的变化,也只需要修改一下 __idx 成员即可。

仿佛不如字典简单?确切来说,这种方法有自己的适用场景:当 A 函数获取 info.get_item 信息后需要进行 postA 的后处理,当 B 函数获取 info.get_item 信息后需要进行 postB 的后处理。这样,就可以把 postApostB 放入到 class Info 中,将分散到各地的相同逻辑的代码整合到一起。至于 "info1""info2" 这种硬编码,也可以用前面讲的东西规避掉。

需要注意的是,这种实现是比较耗时的。如果这个方法到处被调用,会增加程序的执行时间。耗时这一点是通过 py-spy + speedscope 这两个工具发现的,推荐一下这两个工具,用来观察 python 代码中的性能瓶颈。

关于代码的组织架构

文件、文件夹都要做好各司其职,不要怕麻烦,写好 __init__.py,不要把很多文件胡乱的扔到单个文件夹里随意的调用,甚至没有文件夹。时间长了或者当别人用的时候,真的很乱。这次任务我实现了经典的 MVC 模式。

  • model 就是数据解析,存储和维护一些数据结构,如果想要的数据不能直接获取,也可以在 model 里增加一些获取数据的接口。建议将 model 封装为一个类,在一个方法里读取文件,解析得到数据结构,并放到类成员中,方便接口调用获取数据,也避免重复读文件和数据传来传去带来的拷贝开销。交由一个对象去维护数据,由对象的接口去操作数据。而不是将数据读取放到全局变量,任由各个代码、各个函数随意操作。
  • view 是数据的展示,以什么形式和结构展示给用户,显示界面、写出文件或命令行输出等形式;
  • control 是交互的控制,用于捕捉用户请求,按照请求访问 model 的接口并获得想要的数据,再调用 view 接口反馈给用户。

当需要获取很多种类型的数据时,开发重点在 model 部分,因为 control 只是调用获取数据的接口,view 只是展示数据。当需要 A 类型的数据时,control 调用 modelgetA() 方法即可,当需要 B 类型的数据时,调用 modelgetB() 方法。

重点就是这两个方法去如何实现,如何设计高效的数据结构去维护数据,来减少数据的拷贝和优化获取数据的效率。总不能 getA() 的时候重新读文件,getB() 的时候再去读文件,对吧。这就需要在 model 部分下工夫,比如这次就用到了数据结构中经典的 dfs+树的后根法快速解析了数据。leetcode 没白刷了属于是

关于代码维护

额外的,在开发 model 时也有其他的收获:写代码尽可能将各个模块独立封装,写出高内聚,低耦合的传说级代码。虽然当函数很多时会很看着有些乱,怎么到处是函数?但是也有重要的优点:代码和数据重用方便。比如要增加一个新功能,只需要写一点函数,其他函数也许已经实现了,我们直接调用就好,而且不易出错。

如果写一个大函数完成一个功能 A,在写另外一个大函数完成功能 B,这两个大函数操作的变量会有重叠,也会有一些重复的逻辑。当其中的逻辑过于复杂时,难免出错。十分建议将功能剥离开来。

这种低耦合+配置文件的形式也可以灵活的解决一些暂时不确定的场景。领导告诉你说:暂时有 A,B,C,D,E 这五种类型,需要分类处理,后面可能会有改动。你兴冲冲的把这些类型作为字典的 key 完成了分类处理。

某天领导又说,把 A,B,C 归类为类型 1,把 D,E 归类为类型 2,根据不同的类型创建不同的文件夹,但是后面可能还会变动。不到半小时,又收到通知说把 D 归为类型 1,A 的名字改为 Afunc,删除类型 2,并增加 F,G,H 为类型 3。既要修改类型,又要映射关系,去大段的代码函数里修改这些内容真的很累的,也很容易出错。这个时候可以在配置文件里写一个映射函数,每次修改这个小函数并调用就可以了。

1
2
3
4
5
6
7
def map(name):
if name in ["Afunc", "B", "C", "D"]:
return 1
elif name in ["F", "G""H"]:
return 3
else:
return -1

总结,不要假设需求是不变的,这样写出来的代码很烂;需求发生改变时,代码修改难度也很大。

  • 它就应该是这样,不存在其他情况;
  • 这种情况不会出现,就先不考虑了;

程序员最好杜绝以上想法,不然写代码一时爽,改代码火葬场。场景会发生变化,需求永远是在变化。异常情况做好处理,减少代码的硬编码,降低代码功能的耦合度,针对接口编程,学过的设计模式也都可以用起来。避免需求发生变化时大量的修改代码,尽可能通过增加新接口和新函数来适应新的需求。

感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章