【问题标题】:Store json-like hierarchical data as nested directory tree?将类似 json 的分层数据存储为嵌套目录树?
【发布时间】:2019-12-28 23:04:29
【问题描述】:

TLDR

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

背景

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

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

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

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

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

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

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

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

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

天真的方法

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

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

问题

我自己有不同的想法。

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

相关问题

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

【问题讨论】:

    标签: json git serialization yaml tree-structure


    【解决方案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.yamlbeans/ 中不需要,因为它是一个序列)。虽然确切的名称是任意的,但这是在其他几个工具中使用的约定,例如带有__init__.py 的Python 或带有default.nix 的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)
    

    【讨论】:

    • 有趣的答案!但是有一个问题:如果文件和目录名称不再对应于数据键,那么它们现在就变得有些随意了。例如。 “spam”的“spammap”似乎是一个自定义的名称。要自动执行此操作,需要有一个可重现和可重复的模式来生成这些名称。
    • 您的回答的好处:对于阅读,我们仅使用 YAML 标准中的已知约定。仅仅为了写作,我们需要一个额外的约定(和实现)来确定规范的文件名和目录名。人类可以创建具有非规范名称的文件,然后使用工具“规范化”。
    • 理想的情况是从字符串键到文件名/目录名的 1:1 映射。然而,这不会飞,因为可能的字符串键集大于可能的文件名和目录名的集(由于某些文件系统中的长度限制)。这尤其适用于文件或目录名称应该是人类可读的情况,如果我们在名称中添加某种哈希值则更少。有时需要根据同一目录中的其他条目重命名文件或目录。我们希望这是一种罕见的情况,以尽量减少 git 中的混乱。
    • 生成文件名的常见模式是获取相应的映射键并将它们转换为 ASCII/删除空格(为了完美,您需要检查冲突)。大多数语言都有相应的工具。对于大多数 YAML 实现,您可以对应该拆分到另一个文件中的数据使用特殊类型(例如,从 PyYAML 中的 YAMLObject 派生);然后,您只需要为该类型提供自定义表示/构造。您甚至可以在对象中存储非规范名称,然后将修改保存在原始文件中。
    • 我用 PyYAML 添加了一个 POC。我不知道你是否可以在 PHP 中做类似的事情(这很大程度上取决于覆盖 [] 运算符);如果没有,您需要从原始结构到带有SplitValue 的结构的预处理步骤。您还需要在加载时单独存储原始布局,以确保以同样的方式保护修改后的数据。顺便说一句,.yamlFAQ 推荐的。
    猜你喜欢
    • 1970-01-01
    • 2019-03-19
    • 1970-01-01
    • 1970-01-01
    • 2021-11-24
    • 2010-09-13
    • 1970-01-01
    • 2016-03-18
    • 1970-01-01
    相关资源
    最近更新 更多