确定文件相对于目录的路径,包括符号链接。

8
我有一个目录,它有成千上万个子目录(至少1,000个,可能不超过20,000个)。给定文件路径(保证存在),我想知道该文件在该目录中的位置 - 包括通过符号链接。例如,给定:
- 目录路径为 `/base`。 - 真实文件路径为 `/elsewhere/myfile`。 - `/ base` 是指向 `/realbase` 的符号链接 - `/ realbase/foo` 是指向 `/elsewhere` 的符号链接。 - `/ realbase/bar/baz` 是指向 `/elsewhere/myfile` 的符号链接。
我想找到路径 `/base/foo/myfile` 和 `/base/bar/baz`。我可以通过递归检查`/base`中的每个符号链接来完成此操作,但这将非常缓慢。希望能有更优雅的解决方案。
动机:这是为Sublime Text插件而做的。当用户保存文件时,我们要检测它是否在Sublime配置目录中。特别是,即使文件从配置目录内的符号链接创建,且用户正在编辑其物理路径下的文件(例如,在其Dropbox目录中),我们也要检测到。可能还有其他应用程序。Sublime运行在Linux、Windows和Mac OS上,因此最好使用跨平台的解决方案。

2
这个问题类似于:https://dev59.com/0W855IYBdhLWcg3wGQXY。基本的查找方法是你必须到处搜索。我的直觉告诉我,你应该维护一个数据结构来索引所有文件的链接,并在创建新符号链接时进行更新。 - stacksonstacks
由于所请求的操作系统及其文件系统的工作方式不同,您可能需要在运行时选择特定于操作系统的模块。 - Attie
这是针对Sublime Text插件的。我建议这不是你的工作,甚至可能被视为恶意操作......不行,不行,不行。IDE已经臃肿而缓慢了,这个“功能”只会加剧这种情况。 - Attie
你完全忽略了我评论的第一部分...扫描/监视/监控/等整个VFS以尝试确定配置文件是否已更改的IDE是一个坏主意,这甚至被边缘情况要求 "也许有人将他们的配置符号链接到他们的Dropbox中" 更糟。 我会强烈主张,作为插件开发人员,_这不是你的工作_。 也许可以将其切换为“_如果这些其他目录中的文件更改,则重新加载配置_”,并允许用户将其Dropbox目录添加到该列表中。 - Attie
只需檢查編輯器配置文件,而不是文件系統中的每個文件。我同意監視整個文件系統是沒有意義的。 - Thom Smith
显示剩余3条评论
3个回答

2
这个问题,像许多其他事情一样,比表面上看起来的要复杂得多。
文件系统中的每个实体都指向一个“inode”,该inode描述了文件的内容。实体是您看到的东西 - 文件、目录、套接字、块设备、字符设备等等...
单个“文件”的内容可以通过一个或多个路径访问 - 这些路径中的每一个被称为“硬链接”。硬链接只能指向同一文件系统上的文件,不能跨越文件系统的边界。
还可以使用路径来寻址“符号链接”,它可以指向另一个路径 - 该路径不必存在,它可以是另一个符号链接,它可以在另一个文件系统上,或者它可以指回原始路径,从而产生无限循环。
不可能定位指向特定实体的所有链接(符号或硬链接)而不扫描整个树。
在我们深入讨论之前,先说几句:
  1. 请看结尾处的一些基准测试。虽然这个文件系统是在一个六盘 ZFS 阵列上的 i7 上运行的,所以在使用低规格系统时会花费更长时间,但我并不认为这是一个重要问题...
  2. 考虑到在某个时刻无法避免需要对每个文件调用 stat(),你可能很难找到一个更好的解决方案,而不会显著增加复杂性(例如维护索引数据库,并引入所有这些问题)。

如下所述,我们必须扫描(索引)整个树。我知道这不是你想要做的,但如果不这样做就不可能...
为了做到这一点,你需要收集inode而不是文件名,并在事后进行审查... 这里可能有一些优化,但我尝试保持简单以优先理解。
下面的函数将为我们生成这个结构:
def get_map(scan_root):
    # this dict will have device IDs at the first level (major / minor) ...
    # ... and inodes IDs at the second level
    # each inode will have the following keys:
    #   - 'type'     the entity's type - i.e: dir, file, socket, etc...
    #   - 'links'    a list of all found hard links to the inode
    #   - 'symlinks' a list of all found symlinks to the inode
    # e.g: entities[2049][4756]['links'][0]     path to a hard link for inode 4756
    #      entities[2049][4756]['symlinks'][0]  path to a symlink that points at an entity with inode 4756
    entity_map = {}

    for root, dirs, files in os.walk(scan_root):
        root = '.' + root[len(scan_root):]
        for path in [ os.path.join(root, _) for _ in files ]:
            try:
                p_stat = os.stat(path)
            except OSError as e:
                if e.errno == 2:
                    print('Broken symlink [%s]... skipping' % ( path ))
                    continue
                if e.errno == 40:
                    print('Too many levels of symbolic links [%s]... skipping' % ( path ))
                    continue
                raise

            p_dev = p_stat.st_dev
            p_ino = p_stat.st_ino

            if p_dev not in entity_map:
                entity_map[p_dev] = {}
            e_dev = entity_map[p_dev]

            if p_ino not in e_dev:
                e_dev[p_ino] = {
                    'type': get_type(p_stat.st_mode),
                    'links': [],
                    'symlinks': [],
                }
            e_ino = e_dev[p_ino]

            if os.lstat(path).st_ino == p_ino:
                e_ino['links'].append(path)
            else:
                e_ino['symlinks'].append(path)

    return entity_map

我已经制作了一个长这样的样例树:
$ tree --inodes
.
├── [  67687]  4 -> 5
├── [  67676]  5 -> 4
├── [  67675]  6 -> dead
├── [  67676]  a
│   └── [  67679]  1
├── [  67677]  b
│   └── [  67679]  2 -> ../a/1
├── [  67678]  c
│   └── [  67679]  3
└── [  67687]  d
    └── [  67688]  4

4 directories, 7 files

这个函数的输出是:
$ places
Broken symlink [./6]... skipping
Too many levels of symbolic links [./5]... skipping
Too many levels of symbolic links [./4]... skipping
{201: {67679: {'links': ['./a/1', './c/3'],
               'symlinks': ['./b/2'],
               'type': 'file'},
       67688: {'links': ['./d/4'], 'symlinks': [], 'type': 'file'}}}

如果我们对./c/3感兴趣,那么你可以看到,仅查看符号链接(忽略硬链接)会导致我们错过./a/1...

随后在搜索我们感兴趣的路径时,我们可以找到此树中的所有其他引用:
def filter_map(entity_map, filename):
    for dev, inodes in entity_map.items():
        for inode, info in inodes.items():
            if filename in info['links'] or filename in info['symlinks']:
                return info

$ places ./a/1
Broken symlink [./6]... skipping
Too many levels of symbolic links [./5]... skipping
Too many levels of symbolic links [./4]... skipping
{'links': ['./a/1', './c/3'], 'symlinks': ['./b/2'], 'type': 'file'}

此演示的完整源代码如下。请注意,我使用了相对路径来保持简单,但最好更新为使用绝对路径。此外,任何指向树外部的符号链接当前都没有相应的 link...这是读者自行解决的问题。
在填充树时收集数据也可能是一个好主意(如果这适用于您的过程)...您可以使用 inotify 来很好地处理这个问题 - 甚至有一个 Python 模块
#!/usr/bin/env python3

import os, sys, stat
from pprint import pprint

def get_type(mode):
    if stat.S_ISDIR(mode):
        return 'directory'
    if stat.S_ISCHR(mode):
        return 'character'
    if stat.S_ISBLK(mode):
        return 'block'
    if stat.S_ISREG(mode):
        return 'file'
    if stat.S_ISFIFO(mode):
        return 'fifo'
    if stat.S_ISLNK(mode):
        return 'symlink'
    if stat.S_ISSOCK(mode):
        return 'socket'
    return 'unknown'

def get_map(scan_root):
    # this dict will have device IDs at the first level (major / minor) ...
    # ... and inodes IDs at the second level
    # each inode will have the following keys:
    #   - 'type'     the entity's type - i.e: dir, file, socket, etc...
    #   - 'links'    a list of all found hard links to the inode
    #   - 'symlinks' a list of all found symlinks to the inode
    # e.g: entities[2049][4756]['links'][0]     path to a hard link for inode 4756
    #      entities[2049][4756]['symlinks'][0]  path to a symlink that points at an entity with inode 4756
    entity_map = {}

    for root, dirs, files in os.walk(scan_root):
        root = '.' + root[len(scan_root):]
        for path in [ os.path.join(root, _) for _ in files ]:
            try:
                p_stat = os.stat(path)
            except OSError as e:
                if e.errno == 2:
                    print('Broken symlink [%s]... skipping' % ( path ))
                    continue
                if e.errno == 40:
                    print('Too many levels of symbolic links [%s]... skipping' % ( path ))
                    continue
                raise

            p_dev = p_stat.st_dev
            p_ino = p_stat.st_ino

            if p_dev not in entity_map:
                entity_map[p_dev] = {}
            e_dev = entity_map[p_dev]

            if p_ino not in e_dev:
                e_dev[p_ino] = {
                    'type': get_type(p_stat.st_mode),
                    'links': [],
                    'symlinks': [],
                }
            e_ino = e_dev[p_ino]

            if os.lstat(path).st_ino == p_ino:
                e_ino['links'].append(path)
            else:
                e_ino['symlinks'].append(path)

    return entity_map

def filter_map(entity_map, filename):
    for dev, inodes in entity_map.items():
        for inode, info in inodes.items():
            if filename in info['links'] or filename in info['symlinks']:
                return info

entity_map = get_map(os.getcwd())

if len(sys.argv) == 2:
    entity_info = filter_map(entity_map, sys.argv[1])
    pprint(entity_info)
else:
    pprint(entity_map)

我出于好奇在我的系统上运行了这个测试。这是一个由6个磁盘组成的ZFS RAID-Z2池,搭载着i7-7700K处理器,并有足够的数据进行测试。不可否认的是,在规格较低的系统上速度会慢一些...
以下是一些参考性能指标:
  • 包含约3.1k个文件和链接以及850个目录的数据集。 运行时间不到3.5秒,后续运行时间为约80毫秒
  • 包含约30k个文件和链接以及2.2k个目录的数据集。 运行时间不到30秒,后续运行时间为约300毫秒
  • 包含约73.5k个文件和链接以及8k个目录的数据集。 运行时间约为60秒,后续运行时间为约800毫秒
简单计算一下,这意味着每秒大约执行1140次stat()调用(在缓存为空的情况下),或者在缓存填充后,每秒执行约90k次stat()调用——我认为stat()并没有你想象中那么慢!

1
根据目录结构,使用os.scandir可能比为每个文件调用os.stat更有效。它被os.walk实现用于通过使用单个系统调用将子目录中的文件与文件分开,而不是在目录中的每个元素上执行一个os.stat调用。理想情况下,会有一个版本的os.walk,只需产生DirEntry,这样您就可以避免重复。 - Bakuriu
@Bakuriu - 的确... 我忘了优化,但那应该是一个非常明智的第一步... - Attie
在Python 3.3上不支持os.scandir。 - Thom Smith
这可以通过放宽要求来大大加快速度——例如,仅检查与修改文件同名的文件。如果用户使用不同名称的符号链接文件,则可能会失败,但在实践中可能是可以接受的。 - Thom Smith

0

符号链接不支持快捷方式。您必须了解所有可能指向所需文件的相关文件系统条目。这要么对应于创建一个空目录,然后侦听其下所有文件创建事件,要么对当前其下所有文件进行扫描。运行以下命令。

#! /usr/bin/env python

from pathlib import Path
import collections
import os
import pprint
import stat


class LinkFinder:

    def __init__(self):
        self.target_to_orig = collections.defaultdict(set)

    def scan(self, folder='/tmp'):
        for fspec, target in self._get_links(folder):
            self.target_to_orig[target].add(fspec)

    def _get_links(self, folder):
        for root, dirs, files in os.walk(Path(folder).resolve()):
            for file in files:
                fspec = os.path.join(root, file)
                if stat.S_ISLNK(os.lstat(fspec).st_mode):
                    target = os.path.abspath(os.readlink(fspec))
                    yield fspec, target


if __name__ == '__main__':
    lf = LinkFinder()
    for folder in '/base /realbase'.split():
        lf.scan(folder)
    pprint.pprint(lf.target_to_orig)

你最终得到了一个从所有符号链接文件规范到可以访问该文件规范的一组别名的映射。

符号链接目标可能是文件或目录,因此要在给定的文件规范上正确使用映射,您必须重复截断它,并询问父目录或祖先目录是否出现在映射中。

悬挂符号链接不会被特殊处理,它们只允许悬挂。

您可以选择对映射进行串行化,可能按排序顺序。如果您反复重新扫描一个大目录,则有机会在运行之间记住目录模式时间,并避免重新扫描该目录中的文件。不幸的是,您仍然需要递归进入其后代目录,以防其中任何一个最近更改了。

您的子树可能展现出足够的结构,以使您避免递归超过K级,或避免进入名称与某个正则表达式匹配的目录。

如果大多数FS更改由一小部分程序生成,例如软件包管理器或构建系统,则让这些程序记录其操作可能会产生性能优势。也就是说,如果您每天午夜都进行全面扫描,然后您只在一千个目录中的两个目录中运行make,您可以选择仅重新扫描那对子树。


0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接