1

TLDR

我正在寻找一种现有的约定来编码/序列化目录结构中的树状数据,分成小文件而不是一个大文件。

背景

在不同的场景中,我们希望将树状数据存储在文件中,然后可以在 git 中对其进行跟踪。Json 文件可以表达包管理器的依赖关系(例如,php 的 composer,node.js 的 npm)。Yml 文件可以定义路由、测试用例等。

通常,“树结构”是键值列表和“序列”列表的组合,其中每个值都可以再次是树结构。

关联键的顺序通常是无关紧要的,理想情况下应该标准化为字母顺序。

将大树结构存储在单个文件(无论是 json 还是 yml,然后用 git 跟踪)时的一个问题是,如果不同的分支在同一个键值列表中添加和删除条目,则会出现大量合并冲突。

特别是对于顺序不相关的键值列表,将每个子树存储在单独的文件或目录中会更加 git 友好,而不是将它们全部存储在一个大文件中。

从技术上讲,应该可以创建一个像 json 或 yml 一样富有表现力的目录结构。

性能问题可以通过缓存来克服。如果要在 git 中跟踪文件,我们可以假设它们大部分时间都不会改变。

主要挑战: - 如果在文件或目录名称中使用会导致某些或大多数文件系统出现问题的“特殊字符”,如何处理?- 如果我需要对特殊字符进行编码或消除歧义,我怎样才能让它保持美观?- 如何处理某些文件系统中文件名长度的限制?- 如何处理其他文件系统的怪癖,例如不区分大小写?这仍然是一件事吗?- 如何表达序列列表,其中可能包含键值列表作为子项?串行列表不能表示为目录,因此它的子级必须存在于同一个文件中。- 我怎样才能避免重新发明轮子,创造别人没有使用的我自己编造的“惯例”?

所需功能: - 像 json 或 yml 一样富有表现力。- 对 git 友好。- 机器可读和可写。- 人类可读和可编辑,也许有限制。- 理想情况下,对于在单个文件中表达的结构和值,它应该使用已知格式(json、yml)。

天真的方法

当然,第一个想法是将 yml 文件用于文字值和序列列表,并使用目录作为键值列表(在顺序无关紧要的情况下)。在键值列表中,文件或目录名称被解释为键,文件和子目录被解释为值。

这有一些限制,因为不是每个可能在 json 或 yml 中有效的键也是每个文件系统中的有效文件名。最明显的例子是斜线。

问题

我有不同的想法,我自己会怎么做。

但我真的在寻找某种已经存在的约定。

相关问题

持久性:存储为目录树的数据树
这是关于性能的问题,以及关于像数据库一样使用文件系统的问题——我认为。
我对性能不太感兴趣(缓存使其无关紧要),而对实际存储格式/约定更感兴趣。

4

1 回答 1

1

我能想到的最接近的东西可以被视为某种约定,即 Linux 配置文件。在现代 Linux 中,您经常将服务的配置拆分为驻留在某个目录中的多个文件,例如,/etc/exim4/conf.d/而不是只有一个文件/etc/exim/exim4.conf。这样做有多种原因:

  • 一些配置可能由包管理器提供(例如链接到通过包管理器安装的其他服务),而其他部分是用户定义的。由于如果用户编辑包管理器提供的文件会发生冲突,他们可以创建一个新文件并在那里输入其他配置。
  • 对于大型配置文件(例如 exim4),如果您有多个文件用于不同的问题(核心 vim 用户可能不同意),则更容易导航配置。
  • 您可以通过重命名/移动包含特定部分的文件来更轻松地启用/禁用部分配置。

我们可以从中学到一点:如果内容的语义是正交的,即一个文件的语义不依赖于另一个文件的语义,则应该将其分成不同的文件。这当然是兄弟文件的规则;我们不能真正从中推断出将树结构序列化为目录树的规则。但是,我们绝对可以看到不拆分自己文件中的每个值的原因。

您提到将特殊字符编码为文件名的问题。如果您违反约定,您只会遇到这个问题!文件和目录名称的隐含约定是它们充当文件的定位器/ID,而不是内容。同样,我们可以从 Linux 配置文件中学到一些东西:通常,有一个主文件,其中包含一个加载所有拆分文件的 include 语句。include 语句给出了一个路径 glob 表达式,用于定位其他文件。这些文件的路径与其内容的语义无关。从技术上讲,我们可以用 YAML 做类似的事情。

假设我们想将这个 YAML 文件拆分成多个文件(请原谅我缺乏创造力):

spam:
  spam: spam
  egg: sausage
baked beans:
- spam
- spam
- bacon

/可能的转换是这样的(读取以目录结尾的内容,:开始文件内容):

confdir/
  main.yaml:
    spam: !include spammap/main.yaml
    baked beans: !include beans/
  spammap/
    main.yaml:
      spam: !include spam.yaml
      egg: !include egg.yaml
    spam.yaml:
      spam
    egg.yaml:
      sausage
  beans/
    1.yaml:
      spam
    2.yaml:
      spam
    3.yaml:
      bacon

(在 YAML 中,!include是一个本地标签。对于大多数实现,您可以为其注册自定义构造函数,从而将整个层次结构加载为单个文档。)

如您所见,我将每个层次结构级别和每个值都放入一个单独的文件中。我使用两种包含:对文件的引用将加载该文件的内容;对目录的引用将生成一个序列,其中每个项目的值是该目录中一个文件的内容,按文件名排序。如您所见,文件名和目录名从来不是内容的一部分,有时我选择不同的名称(例如baked beans-> beans/)以避免可能的文件系统问题(在这种情况下,文件名中的空格 - 现在通常不是一个严重的问题) . 另外,我遵守文件扩展名约定(让文件带有.yaml)。如果您将内容放入文件名中,这将更加古怪。

我在每个级别上命名了起始文件main.yaml(不需要,beans/因为它是一个序列)。虽然确切的名称是任意的,但这是在其他几个工具中使用的约定,例如 Python 和__init__.pyNix 包管理器default.nix。然后我在这个主文件之外放置了其他文件或目录。

由于包含其他文件是明确的,因此将大部分内容放入单个文件中不是问题。请注意,JSON 缺少 YAML 的标签功能,但您仍然可以浏览已加载的 JSON 文件并预处理诸如{"!include": "path"}.


总结一下:虽然没有直接的约定如何做你想做的事,但部分问题已经在不同的地方得到了解决,你可以从中继承智慧。


这是一个关于如何使用 PyYAML 执行此操作的最小工作示例。这只是一个概念证明;缺少一些功能(例如,自动生成的文件名将是升序,不支持将列表序列化到目录中)。它显示了在对用户透明的同时存储有关数据布局的信息需要做什么(可以像普通的 dict 结构一样访问数据)。它会记住文件名内容已从这些文件中加载并再次存储到这些文件中。

import os.path
from pathlib import Path

import yaml
from yaml.reader import Reader
from yaml.scanner import Scanner
from yaml.parser import Parser
from yaml.composer import Composer
from yaml.constructor import SafeConstructor
from yaml.resolver import Resolver
from yaml.emitter import Emitter
from yaml.serializer import Serializer
from yaml.representer import SafeRepresenter

class SplitValue(object):
  """This is a value that should be written into its own YAML file."""

  def __init__(self, content, path = None):
    self._content = content
    self._path = path

  def getval(self):
    return self._content

  def setval(self, value):
    self._content = value

  def __repr__(self):
    return self._content.__repr__()

class TransparentContainer(object):
  """Makes SplitValues transparent to the user."""

  def __getitem__(self, key):
    val = super(TransparentContainer, self).__getitem__(key)
    return val.getval() if isinstance(val, SplitValue) else val

  def __setitem__(self, key, value):
    val = super(TransparentContainer, self).__getitem__(key)
    if isinstance(val, SplitValue) and not isinstance(value, SplitValue):
      val.setval(value)
    else:
      super(TransparentContainer, self).__setitem__(key, value)

class TransparentList(TransparentContainer, list):
  pass

class TransparentDict(TransparentContainer, dict):
  pass


class DirectoryAwareFileProcessor(object):
  def __init__(self, path, mode):
    self._basedir = os.path.dirname(path)
    self._file = open(path, mode)

  def close(self):
    try:
      self._file.close()
    finally:
      self.dispose() # implemented by PyYAML

  # __enter__ / __exit__ to use this in a `with` construct
  def __enter__(self):
    return self

  def __exit__(self, type, value, traceback):
    self.close()

class FilesystemLoader(DirectoryAwareFileProcessor, Reader, Scanner,
    Parser, Composer, SafeConstructor, Resolver):
  """Loads YAML file from a directory structure."""
  def __init__(self, path):
    DirectoryAwareFileProcessor.__init__(self, path, 'r')
    Reader.__init__(self, self._file)
    Scanner.__init__(self)
    Parser.__init__(self)
    Composer.__init__(self)
    SafeConstructor.__init__(self)
    Resolver.__init__(self)

def split_value_constructor(loader, node):
  path = loader.construct_scalar(node)
  with FilesystemLoader(os.path.join(loader._basedir, path)) as childLoader:
    return SplitValue(childLoader.get_single_data(), path)

FilesystemLoader.add_constructor(u'!include', split_value_constructor)

def transp_dict_constructor(loader, node):
  ret = TransparentDict()
  ret.update(loader.construct_mapping(node, deep=True))
  return ret

# override constructor for !!map, the default resolved tag for mappings
FilesystemLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
    transp_dict_constructor)

def transp_list_constructor(loader, node):
  ret = TransparentList()
  ret.append(loader.construct_sequence(node, deep=True))
  return ret

# like above, for !!seq
FilesystemLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG,
    transp_list_constructor)


class FilesystemDumper(DirectoryAwareFileProcessor, Emitter,
    Serializer, SafeRepresenter, Resolver):
  def __init__(self, path):
    DirectoryAwareFileProcessor.__init__(self, path, 'w')
    Emitter.__init__(self, self._file)
    Serializer.__init__(self)
    SafeRepresenter.__init__(self)
    Resolver.__init__(self)

    self.__next_unique_name = 1
    Serializer.open(self)

  def gen_unique_name(self):
    val = self.__next_unique_name
    self.__next_unique_name = self.__next_unique_name + 1
    return str(val)

  def close(self):
    try:
      Serializer.close(self)
    finally:
      DirectoryAwareFileProcessor.close(self)

def split_value_representer(dumper, data):
  if data._path is None:
    if isinstance(data._content, TransparentContainer):
      data._path = os.path.join(dumper.gen_unique_name(), "main.yaml")
    else:
      data._path = dumper.gen_unique_name() + ".yaml"
  Path(os.path.dirname(data._path)).mkdir(exist_ok=True)
  with FilesystemDumper(os.path.join(dumper._basedir, data._path)) as childDumper:
    childDumper.represent(data._content)
  return dumper.represent_scalar(u'!include', data._path)

yaml.add_representer(SplitValue, split_value_representer, FilesystemDumper)

def transp_dict_representer(dumper, data):
  return dumper.represent_dict(data)

yaml.add_representer(TransparentDict, transp_dict_representer, FilesystemDumper)

def transp_list_representer(dumper, data):
  return dumper.represent_list(data)

# example usage:

# explicitly specify values that should be split.
myData = TransparentDict({
  "spam": SplitValue({
    "spam": SplitValue("spam", "spam.yaml"),
    "egg": SplitValue("sausage", "sausage.yaml")}, "spammap/main.yaml")})

with FilesystemDumper("root.yaml") as dumper:
  dumper.represent(myData)

# load values from stored files.
# The loaded data remembers which values have been in which files.
with FilesystemLoader("root.yaml") as loader:
  loaded = loader.get_single_data()

# modify a value as if it was a normal structure.
# actually updates a SplitValue
loaded["spam"]["spam"] = "baked beans"
# dumps the same structure as before, with the modified value.
with FilesystemDumper("root.yaml") as dumper:
  dumper.represent(loaded)
于 2019-12-29T12:45:39.743 回答