# 前言

在许多工程项目中,经常需要配置一些选项供用户或者员工自己使用。以人工智能领域为例, Paddlepaddlemmdetection 等深度学习框架都需要根据需求在配置文件中配置数据、网络等相关参数。 Paddlepaddle 使用 YAML 格式存储配置信息, mmdetection 则直接使用 Python 文件来设定配置。此外, JSON 格式也是常用的数据存储格式。无论何种格式,当配置信息不断增加膨胀之后,如何组织配置的结构就成为了一件迫切需要考虑的事情。

这三种配置格式孰优孰劣,我无法评价。由于我目前接手的工作中使用的是 YAML 格式的配置文件,因此本文主要介绍最近一段时间经过调研搜索后,我决定采用的一种配置文件组织方案。

在实际阐述方案之前,有必要阐述一下 YAMLdataclass 这两个概念。如果读者对这二者比较熟悉,可以直接跳过相关部分。

# YAML 中的 tag

YAML 诞生于 2004 年,根据官网说法[1],其有 7 条设计目标,其中,易读、不同编程语言间良好的兼容性、可扩展等特性使得其被广泛用于存储格式化的信息。

关于其具体的语法,可以参考官方网站或者其他教程,这里不做过多介绍,本文主要介绍其 tag 标签功能。

tag 可以标注 YAML 中数据的类型或者其归属的对象类别,其可以是 strintfloat 等基本类型,也可以是用户自己定义的任何有意义的类别名称。这些标签可以帮助我们在阅读或者解析的时候,更方便地判断数据的归属。

我们不妨来看一个例子:

name: 'linn'
age: 18
gender: 'male'

在上述 YAML 文件中,我们定义了三个属性,可以很清晰地看到,这三个属性描述的都是个人信息,它们在许多场景下通常会结伴出现。对于这种数据,有一个专门的称呼叫做数据泥团(Data Clumps)[2]。有时候,我们会发现某些函数包含大量的参数,而这些参数往往一起出现,很显然,在通常的编程语言中,用结构体或者类来组织这种数据,比直接使用原生类型是更好的选择。而在 YAML 中,我们也可以用 tag 来表征这一特点:

!Person
name: 'linn'
age: 18
gender: 'male'

YAML 中,使用 ! 来表示这一标识是一个 tag 的名称。相比于仅仅列出属性,增加一个 tag 可以使得数据的语义更加明晰。当我们的配置文件中的数据项膨胀到数十个的时候,增加一个 tag 能够帮助你更好的理解配置选项的意义。

# 解析 YAMLtag

当然,光在 YAML 中定义一个 tag ,并没有全部发挥出 tag 的作用。 tag 黑能配合编程语言,帮助解析 YAML 文件中的内容。

想象一下,当没有 tag 的时候,我们通常是如何解析 YAML 的?一般情况的代码大致如下(为了方便,直接在文件中用字符串表示 YAML 内容):

import yaml
class Person:
    def __init__(self, name, age, gender):
        self.name = name
        self.age = age
        self.gender = gender
yaml_str = """
name: 'linn'
age: 18
gender: 'male'
"""
content = yaml.load(yaml_str, Loader=yaml.SafeLoader)
"""
content
{'name': 'linn', 'age': 18, 'gender': 'male'}
"""
person = Person(**content)

以上代码可以分为两个部分:

  1. 读取 YAML 文件的内容,赋值给某个对象。一般而言,读入的内容会存储在字典或者列表中;
  2. 创建某个类的对象,利用读取的 YAML 内容为其初始化。 对于结构简单的 YAML 文件,上述方法还可接受,但是如果 YAML 文件变得复杂,那么按照上述方法来解析的代码便会变得繁琐。

我们可以看下面这个例子[3]

name: MyBusiness
locations:
  - "Hawaii"
  - "India"
  - "Japan"
employees:
  - !Employee
    name: Matthew Burruss
    id: 1
  - !Employee
    name: John Doe
    id: 2

显然,如果 employees 中的内容较多,我们甚至还需要写一个循环来初始化这些对象。而通过结合 YAMLtag ,我们可以自定义 constructor 来解析其内容:

import yaml
class Employee:
  """Employee class."""
  def __init__(self, name, id):
    self._name, self._id = name, id
def employee_constructor(loader: yaml.SafeLoader, node: yaml.nodes.MappingNode) -> Employee:
  """Construct an employee."""
  return Employee(**loader.construct_mapping(node))
def get_loader():
  """Add constructors to PyYAML loader."""
  loader = yaml.SafeLoader
  loader.add_constructor("!Employee", employee_constructor)
  return loader
yaml.load(open("config.yml", "rb"), Loader=get_loader())
"""
{
  'name': 'MyBusiness',
  'locations': ['Hawaii', 'India', 'Japan'],
  'employees': [
    <__main__.Employee object at 0x7f0ea2694d10>,
    <__main__.Employee object at 0x7f0ea2694d90>
  ]
}
"""

这样一来,我们可以直接生成对应的类的对象,省去了自己创建的过程。

# dataclass

你是否经常面临下面的情形:

class Person:
    def __init__(self, name, age, gender, ...):
        self.name = name
        self.age = age
        self.gender = gender
        ...

当我们在 __init__ 函数的参数列表中敲击了一系列参数之后,又需要在 __init__ 函数体中,将它们一一赋值给类成员变量。一旦这些变量数量增多,这项工作就成了一项非常繁琐无趣的事情。

很多时候,我们定义类只是想将一些相关的数据组织起来,但却不得不动手编写许多重复的代码。于是乎,这里就轮到 dataclass 出场了。类比而言, dataclass 可以看成是 C/C++ 中的结构体,能够方便我们将一系列的数据组合在一起,同时给每个成员指定默认值,不用再手动为成员变量赋值。

可以看下面这个例子[4]

from dataclasses import dataclass
@dataclass
class Lang: 
	"""a dataclass that describes a programming language"""
	name: str = 'python'
	strong_type: bool = True
	static_type: bool = False
	age: int = 28

这里定义了一个编程语言的类 Lang ,我们从 dataclasses 模块中引入了装饰器 dataclass 。之后,我们在类中定义了一系列成员变量。

使用上述方式定义之后,该类会自动生成一个初始化构造函数:

def __init__(self, name: str='python',
            strong_type: bool=True,
            static_type: bool=False,
            age: int=28):
    self.name = name
    self.strong_type = strong_type
    self.static_type = static_type
    self.age = age

除此之外, __repr____eq__ 函数也会自动生成,便于打印和比较。通过以上方式,再也不用每次都自己定义一个构造函数,并且为每个变量都赋值一遍,省却了繁琐的操作。

执行效果如下:

>>> Lang()
Lang(name='python', strong_type=True, static_type=False, age=28)
>>> Lang('js', False, False, 23)
Lang(name='js', strong_type=False, static_type=False, age=23)
>>> Lang('js', False, False, 23) == Lang()
False
>>> Lang('python', True, False, 28) == Lang()
True

除此之外, dataclass 可以通过 asdict 或者 astuple 生成类成员的字典或者元组。

需要注意的是,对于 mutable 类型的变量,如 listmap 等,推荐使用 field 来初始化:

from dataclasses import dataclass, field
@dataclass
class C:
    mylist: List[int] = field(default_factory=list)

其他功能的详细介绍,可以参考这篇博客或者官方网站的说明。

# 结合 dataclassYAML

在介绍以上两部分内容之后,我们可以结合 dataclassYAML 来方便地构建配置类,并从 YAML 文件导入,或者导出到 YAML 文件。

参考了相关资料后[3:1][5],我编写了下面的参数类模板。

import sys
import yaml
from yaml import SafeLoader, SafeDumper
from yaml.nodes import MappingNode
from dataclasses import dataclass, asdict, field, is_dataclass
from typing import Type
from typing_extensions import Self
@dataclass
class BaseConfig(object):
    @classmethod
    def constructor(cls, loader: SafeLoader, node: MappingNode) -> Self:
        """Construct an instance."""
        return cls(**loader.construct_mapping(node))
    @classmethod
    def loader(cls, safe_loader: SafeLoader) -> Type[SafeLoader]:
        """Add constructors to PyYAML loader."""
        safe_loader = yaml.SafeLoader
        safe_loader.add_constructor(f"!{cls.__name__}", cls.constructor)
        for (name, data_fields) in cls.__dataclass_fields__.items():
            cls_type = data_fields.type
            if is_dataclass(cls_type):
                safe_loader.add_constructor(f"!{cls_type.__name__}", cls_type.constructor)
                safe_loader = cls_type.loader(SafeLoader)
        return safe_loader
    @classmethod
    def representer(cls, dumper: SafeDumper, config) -> MappingNode:
        """Represent an instance as a YAML mapping node."""
        return dumper.represent_mapping(f"!{cls.__name__}", config.__dict__)
    @classmethod
    def dumper(cls, safe_dumper: SafeDumper) -> Type[SafeDumper]:
        """Add representers to a YAML seriailizer."""
        # safe_dumper = yaml.SafeDumper
        safe_dumper.add_representer(cls, cls.representer)
        for (name, data_fields) in cls.__dataclass_fields__.items():
            cls_type = data_fields.type
            if is_dataclass(cls_type):
                safe_dumper.add_representer(cls_type, cls_type.representer)
                safe_dumper = cls_type.dumper(safe_dumper)
        return safe_dumper
@dataclass
class DatasetConfig(BaseConfig):
    data_root_path: str = "./data/coco"
    train_path: str = "./data/coco/train"
    train_ann_path: str = "./data/coco/annotations/instances_train2017.json"
@dataclass
class COCODatasetConfig(BaseConfig):
    name: str = "COCO"
    num_classes: int = 80
    dataset: DatasetConfig = field(default_factory=DatasetConfig)
@dataclass
class COCODataConfig(BaseConfig):
    train_data: COCODatasetConfig = field(default_factory=COCODatasetConfig)
    val_data: COCODatasetConfig = field(default_factory=COCODatasetConfig)
    # If you want dump without tag, change the tag name of the class
    # to 'tag:yaml.org,2002:map', this is the default map type of YAML
    # @classmethod
    # def representer(cls, dumper: SafeDumper, config) -> MappingNode:
    #     """Represent an instance as a YAML mapping node."""
    #     return dumper.represent_mapping("tag:yaml.org,2002:map", config.__dict__)
if __name__ == "__main__":
    # config = COCODatasetConfig()
    # config = yaml.load(open("output.yaml", "rb"), Loader=COCODataConfig.loader(SafeLoader))
    config = COCODataConfig()
    print(config)
    print(asdict(config))
    # config = DatasetConfig()
    # with open("output.yaml", "w") as stream:
        # stream.write(yaml.dump(config, Dumper=COCODatasetConfig.dumper()))
        # stream.write(yaml.dump(config, Dumper=COCODataConfig.dumper(SafeDumper)))
    # yaml.dump(config, sys.stdout, Dumper=COCODatasetConfig.dumper(), sort_keys=False)
    yaml.dump(config, sys.stdout, Dumper=COCODataConfig.dumper(SafeDumper), sort_keys=False)

上述模板可以支持多个 dataclass 类别的嵌套,并且会保留每个类别的 tag 标签,便于将来再读取配置的内容。

如果不想输出某个 dataclass 类的 tag ,则重载其 representerloader 函数,修改其中设定的 tag 名称为 tag:yaml.org,2002:map [6]。其他类型可以参看官方文档[7]

如果某些变量依赖于其他变量的赋值,可以使用 __post_init__ 方法:

@dataclass
class C:
    a: int
    b: int
    c: int = field(init=False)
 
    def __post_init__(self):
        self.c = self.a + self.b

此外,如果需要额外的参数用于初始化,但是之后的程序中不需要用到它的话,可以指定一个 field 的类型注解为 dataclasses.InitVar ,那么这个 field 将只能在初始化过程中( __init____post_init__ )使用,当初始化完成后访问该 field 会返回一个 dataclasses.Field 对象而不是 field 原本的值,也就是该 field 不再是一个可访问的数据对象。比如一个由数据库对象,它只需要在初始化的过程中被访问

@dataclass
class C:
    i: int
    j: int = None
    database: InitVar[DatabaseType] = None
 
    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')
 
c = C(10, database=my_database)

database 只在初始化过程中用于初始化 i, j ,后续无法再访问,可以认为是传递了一个额外的参数用于初始化操作。

# 总结

通过以上内容,我们得到了一个用于 YAML 格式的通用模板类,用于导入或者导出相关的配置内容,帮助我们更好地管理配置文件中参数的结构,提高工作效率。减少加班


  1. YAML Ain’t Markup Language (YAML™) revision 1.2.2 ↩︎

  2. Data Clumps (refactoring.guru) ↩︎

  3. 来自博客 A Powerful Python Trick: Custom YAML tags & PyYAML | Matthew Burruss (matthewpburruss.com) ↩︎ ↩︎

  4. 来自博客 Python3.7 dataclass 使用指南 - apocelipes - 博客园 (cnblogs.com) ↩︎

  5. dataclass を使った YAML 形式で保存/ロード可能な設定クラス - Qiita ↩︎

  6. PyYAML 笔记 - 一个单板滑雪爱好者的编程笔记 (caosiyang.github.io) ↩︎

  7. Language-Independent Types for YAML™ Version 1.1 ↩︎