将 json-like 层级数据存储为嵌套目录树?

Store json-like hierarchical data as nested directory tree?

TLDR

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

背景

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

通常 "tree structure" 是 key-value 列表和 "serial" 列表的组合,其中每个值又可以是树结构。

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

在单个文件中存储大树结构时的一个问题,无论是 json 还是 yml,然后用 git 跟踪,如果不同的分支,您会遇到大量合并冲突在同一 key-value 列表中添加和删除条目。

特别是对于 key-value 顺序无关紧要的列表,git-friendly 将每个 sub-tree 存储在单独的文件或目录中,而不是将它们全部存储在一个文件或目录中会更好 git-friendly大文件。

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

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

主要挑战: - 如果在文件或目录名中使用 "special characters",如何处理在某些或大多数文件系统中引起问题的问题? - 如果我需要对特殊字符进行编码或消除歧义,我怎样才能让它看起来赏心悦目? - 如何处理某些文件系统中文件名长度的限制? - 如何处理其他文件系统问题,例如不区分大小写?这还是一回事吗? - 如何表达序列列表,其中可能包含 key-value 列表作为 children?序列列表不能表示为目录,因此它的 children 必须存在于同一个文件中。 - 我怎样才能避免重新发明轮子,创造我自己的 made-up "convention" 而没有其他人使用?

需要的功能: - 与 json 或 yml 一样富有表现力。 - git-friendly。 - Machine-readable 和 - 可写。 - Human-readable 和 -editable,也许有限制。 - 理想情况下,它应该对在单个文件中表达的结构和值使用已知格式(json、yml)。

天真的方法

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

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

问题

我自己有不同的想法。

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

相关问题

Persistence: Data Trees stored as Directory Trees
这是在询问性能,以及关于像数据库一样使用文件系统的问题 - 我认为。
我对性能不太感兴趣(缓存使其无关紧要),而对实际存储格式/约定更感兴趣。

我能想到的最接近的可以被视为执行此操作的某种约定的是 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__.py 或 Nix 包管理器与 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)