中小型 Python 项目配置和数据层的最佳实践

在编写自用程序的过程中,逐步形成了一套清晰好用的 Python配置文件管理 和数据管理的程序模式,分享以交流。适用于几千行以内的中小项目。

程序穿过 config 和 data 开启核心逻辑运行

程序配置,也就是程序运行依赖的若干值,比如是否在生产环境,管理员用户的账号密码,限制资源占用的变量,数据库的配置等。

由于配置文件一般存储有机密信息,所以不能写在程序文件中,那样也不利于放在一处统一管理,可以用一个文件存储这些配置,并且用专门的配置类在程序初始化时读取配置,并在程序运行期间以单例存在,供其他部分读取配置,绝大部分时间保持只读状态。

配置文件需要手动编写,因此首要的一点是结构清晰简洁、便于观察和编辑,又往往只在启动时被读取一次,因此性能不重要,所以推荐使用 YAML 格式编写。


至于数据管理,其实核心就是抽出程序所需的所有数据读写操作或者进一步程序与外部的所有交互,把这部分逻辑作为单独一个层,在程序的真正业务代码中,通过调用数据层中的方法对外部数据进行读写。

这样不仅将程序核心与外部隔离,解耦降低复杂度,便于并行开发;还能在换用外部依赖时,只需要修改数据层,不用关心程序其他部分;并且,能够为常用的操作专门编写方法,从而降低使用时的繁琐。

另有一点,在数据层中,对外部数据进行验证,确保进入程序核心的数据都是合法的,从而不必在核心代码中还要考虑这些异常。

程序结构

project
│
├── config_and_data_files
│   ├── config.yaml
│   └── some_data.json
│
├── examples
│   ├── config.yaml
│   └── pgm_config.yaml
│
├── .gitignore
├── preproc.py
├── configHandle.py
├── dataHandle.py
└── main.py

说明:

  1. 配置类为 configHandle.py
  2. 数据类为 dataHandle.py
  3. 配置文件的示例放在 examples 中,使用 git 跟踪,方便别人参考
  4. 自定义的配置文件和数据文件放在 config_and_data_files 下,在 .gitignore 中忽略
  5. 至于 preproc.py 也就是预处理 preprocess,请往下看

为了简洁,只能展示大体框架,配合一个具体例子会更容易理解,推荐看看这个项目的代码: https://github.com/AhFeil/source2RSS

preproc.py

定位是执行初始化动作,比如加载配置、加载插件、加载一些数据,下面的例子中,主要功能是

  1. 实例化 configHandle 为 config
  2. 实例化 dataHandle 为 data
# 在导入配置类中会执行其中的代码,实现配置类的实例化,再引入该实例
from configHandle import config

# 如果配置没出错,继续实例化数据类
from dataHandle import data

# 对外暴露 config 和 data 两个实例
__all__ = ["config", "data"]

最初在入口文件中放上面代码,比如是 main.py,但这样容易产生循环引用,比如 main 引用的模块需要配置类中的全局参数。

并且从结构上看,配置是任何一个模块都可能需要的,因此单独放入预处理文件中更合适。而且这部分代码很少改变,拿出来单独成一个文件更清爽。


如果程序本身只有一个或几个文件,并且没有什么数据文件,那么完全可以不创建 config_and_data_files 目录,直接把 config.yaml 放在项目根目录,让项目结构更简单。

配置类

在开始前,先分享一个观点,程序参数可以分为 3 个层级:

  1. 用户级的参数。用户关心的,测试时可能修改的,生产和开发环境下往往不同的,包含机密信息的
  2. 程序级的参数。普通用户不应该改动的,测试时保持不变的;比如日志配置,线程池大小等。
  3. 开发级的参数。剩下的参数,都应该直接在 Config 类的 __init__() 中指定,比如其他代码中用到了路径,或者表的名称,这样程序中可变的量都汇集在一起,便于寻找和修改。

根据上面的分级,配置文件最好有两个,用户级的和程序级的,程序级的可以复用,用户级的往往会修改。当然项目简单一个就够。


当配置的结构复杂,参数较多时,JSON 的嵌套结构会比较混乱,不宜阅读。

YAML 的样式美观,编辑也很容易。TOML 相比就不怎么好看。这里使用 ruamel.yaml 库,它能在修改后,还保持原来的样式(注释位置,空行,列表风格等)

python -m pip install ruamel.yaml

configHandle.py

思路是:

  1. 当 import 此文件时,Python 会自动执行一遍其中的代码,此时从环境变量里获得配置文件的路径
  2. 在文件末尾,实例 Config 时,传入配置文件路径。
  3. Config 初始化时,调用 reload() 把配置项都保存为自身成员,这样在后续还能利用补全。
  4. reload() 由开发人员根据需要,从配置文件导入的字典中,提取数据,加工数据,最后保存为成员,如 self.is_production = configs['is_production'],从而通过 config.is_production 获得这项参数。而获得字典将由 _load_config() 负责。
  5. _load_config() 会先加载第一个配置文件,从中取出其他配置文件的路径并加载,最终合并得到一份配置,返回一个字典。
  6. reload() 这个方法如果在运行中再调用一次,就相当于重载了配置文件,因此将这个方法直接命名为 reload
import logging.config
import os
from contextlib import suppress

from ruamel.yaml import YAML, YAMLError

configfile = os.getenv("MY_PROJECT_CONFIG_FILE", default="config_and_data_files/config.yaml")

# 非线程安全,但在单个事件循环下是协程安全的。如果运行中不调用 reload,那么只存在读取行为,则可以用于多线程
class Config:
    yaml = YAML()

    def __init__(self, config_path: str) -> None:
        self.config_path = config_path
        self.reload()

        # 用户不应该考虑的配置,开发者可以改的
        self.some_cfg = "cfg"

    def reload(self) -> None:
        """将配置文件里的参数,赋予单独的变量,方便后面程序调用"""
        configs = Config._load_config(self.config_path)
        # 默认无须用户改动的
        logging.config.dictConfig(configs["logging"]) # 对日志模块进行配置,不使用删除即可

        # 用户配置
        self.is_production = configs['is_production'] # 配置文件中的参数,手动提取出来

    @staticmethod
    def _load_config(config_path: str) -> dict:
        """加载第一个配置文件,从中取出其他配置文件的路径并加载(文件不存在不报错),最终合并得到一份配置"""
        config = Config._load_config_file(config_path)
        for f in config.get("other_configs_path", ()):
            with suppress(FileNotFoundError):
                other_config = Config._load_config_file(f)
                Config._update(config, other_config)
        return config

    @staticmethod
    def _update(config: dict, other_config: dict):
        """
        遍历新的配置中每个键值对,如果在当前配置中不存在,就新增;存在,若是不可变类型,就用新的覆盖;
        若是列表,就在原有的追加;若是字典,就递归。
        """
        for key, val in other_config.items():
            if key not in config:
                config[key] = val
                continue
            if isinstance(val, (bool, int, float, str)):
                config[key] = val
                continue
            if isinstance(val, list):
                config[key].extend(val)
                continue
            if isinstance(val, dict):
                Config._update(config[key], val)
                continue

    @staticmethod
    def _load_config_file(f) -> dict:
        try:
            with open(f, 'r', encoding="utf-8") as fp:
                return Config.yaml.load(fp)
        except YAMLError as e:
            raise YAMLError(f"The config file is illegal as a YAML: {e}")
        except FileNotFoundError:
            raise FileNotFoundError("The config does not exist")

config = Config(os.path.abspath(configfile))

使用案例:

  1. 在入口文件中,通过 from preproc import config 引入 config,主函数可以直接传 config 对象 给调度器之类的,或者只传 config.is_production 这样的具体变量,取决于程序结构和后续需要的参数
  2. 一个比较独立的模块要使用全局参数,如果依靠传递,那传递链的维护就很繁琐且容易出错,直接通过引入 config: from configHandle import config ,然后,用哪个变量,就 config.variable_name 即可(这里没有从 preproc,而是从源头引入,更显独立,避免循环引用)
  3. 添加参数,比如 name=vfly2 时,只要三步
    1. 在 config_and_data_files/config.yaml 里添加 name: vfly2
    2. 然后在 Config 的 reload() 下分配成员名 self.name=configs['name']
    3. 通过 config.name 调用
  4. 运行时调用 config.reload() 重载配置

配置文件例子

个人开源项目开发和部署两者之间难受的一点,是配置文件的同步,以及时间久了忘记究竟有哪些配置项。因此用 git 跟踪配置,但是考虑到机密信息,以及生产与开发之间确实有差异的配置,选择用多配置文件实现。

对于在生产和开发环境下内容相同的配置,且不涉及机密,而且无须用户关心的,就放在 "examples/pgm_config.yaml" 这里,比如日志的配置、一些数据文件缓存目录。

对于用户会关心的,那么大概率生产和开发环境下内容会有区别,这种配置放在 "examples/config.yaml" 中,作为一个示例,另外直接使用也可以运行,可以作为开发环境的配置,这样能时刻保证配置文件的有效性。

那么怎么加载这两个配置文件,如果传入两个路径,虽然可以,但是配置文件更多呢?不具备扩展性。采用的方法是在 config.yaml 中添加 other_configs_path 字段,里面放上其他配置文件的路径,然后由 Config._load_config() 负责合并,最终表现出和一个配置文件一样。


在生产环境中,复制示例文件到 "config_and_data_files/config.yaml",对其进行自定义,other_configs_path 下填入示例中的其他配置文件,这样实现复用。

如果配置文件在其他路径,则需要通过环境变量传入这个路径

Linux 上设置环境变量:

export PROJECT_CONFIG_FILE=config.yaml

或者在 Windows 中:

set PROJECT_CONFIG_FILE=config.yaml

examples/config.yaml

is_production: true
other_configs_path:   # 靠后的配置文件会覆盖前面的配置
- examples/pgm_config.yaml

examples/pgm_config.yaml

logging:
  version: 1
  disable_existing_loggers: False

  formatters:
    simple:
      format: "%(asctime)s %(message)s"
      datefmt: "%Y-%m-%d %H:%M:%S"
    brief:
      format: "%(levelname)s %(asctime)s %(message)s"
      datefmt: "%m-%d %H:%M:%S"
    error:
      format: "%(levelname)s %(asctime)s %(name)s %(filename)s::%(funcName)s[%(lineno)d]:%(message)s"

  handlers:
    console:
      class: logging.StreamHandler
      level: INFO
      formatter: brief
      stream: ext://sys.stdout

    info_file_handler:
      class: logging.handlers.RotatingFileHandler
      level: INFO
      formatter: simple
      filename: info.log
      maxBytes: 1048576  # 1MB
      backupCount: 3
      encoding: utf8

    error_file_handler:
      class: logging.handlers.RotatingFileHandler
      level: ERROR
      formatter: error
      filename: error.log
      maxBytes: 1048576  # 1MB
      backupCount: 3
      encoding: utf8

  root:
    level: INFO
    handlers: [console, info_file_handler, error_file_handler]

日志模块的使用

建议每个模块创建自己的日志实例

# other_module.py
import logging

logger = logging.getLogger("module_name")
logger.info("输出日志")

可以为每个类都添加各自的日志实例

import logging

class Example:
    def __init__(self):
        self.logger = logging.getLogger(self.__class__.__name__)

    def method_name(self):
        self.logger.info("...")

数据层

由人编写的,用 YAML 更合适。

简单项目运行中产生的数据,很少查看不会手动编辑的,使用 JSON。数据量大的,使用 SQLite 数据库。

dataHandle.py

下面的配置是同步代码下的思考,并发或异步代码下,需要根据情况修改,比如多个用户并发运行,只能各自实例化 data,否则数据会混在一起。

外部数据有三种,这也是数据层要操作的对象

  1. 那些在程序运行时,不会修改内容的,直接读取即可
  2. 运行时,会由外部改变的,需要考虑重载
  3. 运行时,会由程序自身改动的,需要考虑便捷修改和保存数据到硬盘

简单例子仅作示范,因此通过一个类来囊括全部数据层的操作,思路:

  1. 实例化时接收 config (配置类的实例),方便根据配置加载数据
  2. 实例化时要确保必要的数据文件存在,如果不存在就创建,后面读写时就不必考虑了。
  3. 实例化时直接加载运行中不会变化的数据,比如从高德获取天气需要用城市的编码,这种对应关系在启动时直接读入内存即可
  4. 如果数据文件是用户编写的,还要检查格式,保证后续程序不需要考虑不合法的问题
  5. 为常用的操作编写便捷方法,比如保存所有数据到各自文件,重载某个数据。

Data 类很灵活,把握核心思想编写便捷方法应该是主要工作。业务代码中不要出现 with open 或者 r.push() 这种直接的操作,而是调用 data.reload(), data.push_to_list() 这种。如果某个外部数据方法较多,还可以单独用一个类去代表这个数据,然后在 Data 中包含这个类。

reload 应该改成 _reload ,要重载哪个就单独创建一个函数

下面的代码看一下结构就可以了,因为数据这块形式很多,不像配置那么具体,所以还没总结出一套比较完善的代码。

import os
import json
import logging
from enum import Enum

from ruamel.yaml import YAML

from configHandle import config

# 如果需要,可以定义枚举量,避免魔法数字
class TaskType(Enum):
    """定义任务的类型"""
    SIMPLE_RT = 0
    ROLL_POLLING = 1
    LONG_RT = 2

class Data:
    yaml = YAML()

    def __init__(self, config) -> None:
        self.config = config
        self.logger = logging.getLogger(self.__class__.__name__)
        self._make_sure_file_exist()
        self._load_file()

        # 如果使用数据库,这里可以创建对象,然后创建一些常用操作的方法

    def _load_file(self) -> None:
        """加载运行中不会变化的数据和其他数据"""
        self.city_adcode = self._reload(self.config.city_adcode_file)
        # 会变化的
        self.version_data = self._reload(self.config.version_file_path)
        self.original_hash4version = hash(json.dumps(self.version_data, sort_keys=True))

    def _make_sure_file_exist(self) -> None:
        """有些数据文件要确保存在"""
        sample_version = {"sample_project": "v0.01"}
        self._check_file_exist(self.config.version_file_path, sample_version)
        # 可以提供一个可调用对象去检查
        self._check_file_format(self.config.version_file_path, _check_function)

    def _check_file_exist(self, file_path, example_content) -> None:
        """不存在可以填充符合规范的样例内容"""
        if os.path.exists(file_path):
            return
        if example_content:
            self.save(example_content, file_path)

    def _check_file_format(self, file_path, func) -> None:
        """检查数据文件的格式"""
        pass

    @classmethod
    def _reload(cls, file_path):
        """用于加载各种类型的文件"""
        suffix_name = os.path.splitext(file_path)[1]   # 获取后缀名
        with open(file_path, 'r', encoding='utf-8') as f:
            if suffix_name == '.yaml':
                content = cls.yaml.load(f)
            elif suffix_name == '.json':
                content = json.load(f)
            else:
                raise Exception("Unknow file format")
        return content

    @classmethod
    def save(cls, data_in_m, file_path):
        suffix_name = os.path.splitext(file_path)[1]   # 获取后缀名
        with open(file_path, 'w', encoding='utf-8') as f:
            if suffix_name == '.yaml':
                cls.yaml.dump(data_in_m, f)
            elif suffix_name == '.json':
                json.dump(data_in_m, f)
            else:
                raise Exception("Unknow file format")

data = Data(config)

如何使用:

  1. 在入口文件中,通过 from preproc import data 引入 data,推荐主函数直接把 data 传给其他实例或函数
  2. 后面程序中要读写数据时,就通过 data.method() 操作即可,若要增加操作,就在 Data 类中编写,然后通过 data.method() 调用,完全不需要再传递新参数

获取命令行参数

命令行参数比用环境变量更加容易理解,操作更容易。如果配置少,可以直接用命令行传。

使用 argparse

import argparse

# 创建一个解析器
parser = argparse.ArgumentParser(description="Your script description")
# 添加你想要接收的命令行参数
parser.add_argument('--config', required=False, default='config_and_data_files/config.yaml', help='Config File Path')
# 解析命令行参数
args = parser.parse_args()

# 实例化 配置类
from configHandle import Config
config = Config(args.config)

# 如果配置没出错,继续实例化数据类
from dataHandle import Data
data = Data(config)

读取环境变量

Gunicorn、Uvicorn 启动情况下,就不能在 preproc 中引入 argparse 了,因为像是 Uvicorn 本身也读取命令行参数,不方便让它再传递一些参数给后面的程序,这时候可以用环境变量代替。

其实还支持 .env 文件导入环境变量的方式,不过这种写入文件,那还不如直接用 .yaml

.env 文件的内容可能如下所示:

DB_POSTGRES_HOST=localhost
DB_POSTGRES_PORT=5432
APP_WEBSERVER_PORT=80

要加载此 .env 文件,可以使用 python-dotenv 库。该库加载文件但不会覆盖现有的环境变量。

from dotenv import load_dotenv
import os

load_dotenv(".env")

db_postgres_host = os.getenv("DB_POSTGRES_HOST")
db_postgres_port = os.getenv("DB_POSTGRES_PORT")
app_webserver_port = os.getenv("APP_WEBSERVER_PORT")

其他

一个专门负责配置管理的 Python 库: Dynaconf – 3.2.11


这些代码可能有错误,因为我是从自己项目中剪切出来的,还修正了一些过去不合适的地方,没有经过使用,若有误,敬请告知。

原文链接: https://yanh.tech/2024/04/best-practice-for-configuration-and-data-rw-in-small-and-medium-python-projects/

版权声明:本博客所有文章除特別声明外,均为 AhFei 原创,采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技焉洲 (yanh.tech)

保持更新 ٩(•̤̀ᵕ•̤́๑)ᵒᵏᵎᵎᵎᵎ 清晰恒益的实用技能,欢迎使用 RSS 订阅,如果能留言互动就更好了。

可在 Telegram 群组 https://t.me/vfly2 交流依文章步骤遇到的问题。

评论

  1. Bach239
    1 年前
    2024-5-05 18:26:29

    若使用 Dynaconf 組織配置是否會更爲簡潔?

    • Bach239
      1 年前
      2024-5-06 0:00:11

      好像确实不错,功能上能覆盖我文中的配置管理需求。

      不过对于中小型项目,为了配置引入一个包也许有点大题小做。另外自己写配置类也更灵活吧,都是这样。

  2. PinHsin
    1 年前
    2024-5-06 16:58:40

    配置文件中的参数,手动提取出来

    self.is_production = configs[‘is_production’]
    大佬这一步中配置文件的每项都需要手动填写吗

    • PinHsin
      1 年前
      2024-5-06 17:19:53

      目前是这样的,属于重复性劳动。昨天看了 Dynaconf 给了启发,它会按照配置项原本结构自动生成属性,以后探索一下

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇