用于浏览器的客户端虚拟文件系统,适用于分块

A client-side virtual file system for browsers that works with chunking

提问人:0__ 提问时间:10/25/2020 最后编辑:0__ 更新时间:10/25/2020 访问量:1567

问:

我正在尝试移植桌面应用程序的某些部分,以便能够在浏览器(客户端)中运行。我需要一种虚拟文件系统,我可以在其中读取和写入文件(二进制数据)。据我所知,唯一可以跨浏览器广泛使用的选项之一是 IndexedDB。但是,我有点疏远了试图找到读取或写入更大文件的示例。似乎 API 仅支持将整个文件内容传递/获取到数据库(blob 或字节数组)或从数据库(blob 或字节数组)传递/获取。

我试图找到的是,我可以不断地“流式传输”数据,可以说是虚拟文件系统,类似于您在任何其他非浏览器应用程序上执行此操作的方式。例如(伪代码)

val in = new FileInputStream(someURLorPath)
val chunkSize = 4096
val buf = new Array[Byte](chunkSize)
while (in.hasRemaining) {
  val sz = min(chunkSize, in.remaining)
  in.read(buf, 0, sz)
  processSome(buf, 0, sz)
  ...
)
in.close()

我知道同步 API 对浏览器来说是一个问题;如果是异步方法,那也没关系。但是我想逐块浏览文件——它可能很大,例如几个 100 MB。块大小无关紧要。这既适用于阅读,也适用于写作。read

随机访问(能够寻找虚拟文件中的位置)将是一个加分项,但不是强制性的。


我的一个想法是,一个存储 = 一个虚拟文件,然后键是块索引?有点像 MDN 上的光标示例,但每条记录都是一个固定大小的 blob 或数组。这有意义吗?有没有更好的 API 或方法?


从概念上讲,Streams 似乎是我正在寻找的 API,但我不知道如何“流式传输”虚拟文件系统,例如 IndexedDB。

JavaScript 浏览器 客户端 索引数据库 虚拟文件系统

评论

0赞 Dai 10/25/2020
数据是否实际驻留在用户的本地计算机上?或者您可以通过 HTTP 从远程存储服务器流式传输它吗?如果是这样,您可以使用简单的范围请求吗?
0赞 0__ 10/25/2020
@Dai感谢您的提问。事实上,这两种情况都是相关的。想象一下,这些是声音和视频文件。我必须能够从唯一的资源中检索它们并将它们放在本地存储中(并且我不希望在 fetch 和 put 之间的某个时间点在内存中拥有整个 300 MB 的文件),而且 Web 应用程序必须能够在虚拟系统中处理和创建新的声音文件。在大多数情况下,在用户关闭浏览器选项卡后,它们不需要保留,但如果可能的话,那就太好了。
1赞 charlietfl 10/25/2020
听起来您很快就会遇到存储大小限制
0赞 0__ 10/25/2020
@charlietfl 我认为存储管理器报告 2 GB (Firefox),在大多数情况下应该没问题。
0赞 Dai 10/25/2020
是否可以在用户的计算机上运行无头 Web 服务器进程?还是要求他们使用基于 Chromium 的浏览器?如果你只想坚持使用具有广泛浏览器支持的 W3C 建议,那么你就会被任何类型的本地存储选项所困(这甚至没有考虑 IE11 支持)。

答:

3赞 Dai 10/25/2020 #1

假设您希望能够透明地使用本地缓存(且一致)的初始远程资源,则可以抽象(使用请求)和 。fetchRange:IndexedDB

顺便说一句,你真的想使用 TypeScript 来做这件事,因为在纯 JavaScript 中工作是一个 PITA。Promise<T>

可以说是只读或只追加写入。严格来说,我不需要能够覆盖文件内容(尽管这样做会很方便)

像这样的东西..

我从 MDN 的文档中拼凑出来的 - 我还没有测试过它,但我希望它能让你朝着正确的方向前进:

第 1 部分 -LocalFileStore

这些类允许您将任意二进制数据存储在 4096 字节的块中,其中每个块由 .ArrayBuffer

IndexedDB API 一开始令人困惑,因为它不使用原生 ECMAScript,而是使用自己的 -API 和奇怪的命名属性 - 但它的要点是:Promise<T>IDBRequest

  • 一个名为 IndexedDB 的数据库包含本地缓存的所有文件。'files'
  • 每个文件都由其自己的实例表示。IDBObjectStore
  • 每个文件的每个 4096 字节块都由它自己的 record/entry/key-value-pair 表示,其中 是文件的对齐偏移量。IDBObjectStorekey4096
    • 请注意,所有 IndexedDB 操作都发生在上下文中,因此为什么要包装对象而不是对象。IDBTransactionclass LocalFileIDBTransactionIDBObjectStore
class LocalFileStore {
    
    static open(): Promise<IDBDatabase> {
        
        return new Promise<IDBDatabase> ( function( accept, reject ) {
            
            // Surprisingly, the IndexedDB API is designed such that you add the event-handlers *after* you've made the `open` request. Weird.
            const openReq = indexedDB.open( 'files' );
            openReq.addEventListener( 'error', function( err ) {
                reject( err );
            };
            openReq.addEventListener( 'success', function() {
                const db = openReq.result;
                accept( db );
            };
        } );
    }

    constructor(
        private readonly db: IDBDatabase
    ) {    
    }
    
    openFile( fileName: string, write: boolean ): LocalFile {
        
        const transaction = this.db.transaction( fileName, write ? 'readwrite' : 'readonly', 'strict' );
        
        return new LocalFile( fileName, transaction, write );
    }
}

class LocalFile {
    
    constructor(
        public readonly fileName: string,
        private readonly t: IDBTransaction,
         public readonly writable: boolean
    ) {
    }

    getChunk( offset: BigInt ): Promise<ArrayBuffer> {
        
        if( offset % 4096 !== 0 ) throw new Error( "Offset value must be a multiple of 4096." );
       
        return new Promise<ArrayBuffer>( function( accept, reject ) {
        
            const key = offset.ToString()
            const req = t.objectStore( this.fileName ).get( key );
            
            req.addEventListener( 'error', function( err ) {
                reject( err );
            } );
            
            req.addEventListener( 'success', function() {
                const entry = req.result;
                if( typeof entry === 'object' && entry !== null ) {
                    if( entry instanceof ArrayBuffer ) {
                        accept( entry as ArrayBuffer );
                        return;
                    }
                }
                else if( typeof entry === 'undefined' ) {
                    accept( null );
                    return;
                }

                reject( "Entry was not an ArrayBuffer or 'undefined'." );
            } );

        } );
    }

    putChunk( offset: BigInt, bytes: ArrayBuffer ): Promise<void> {
        if( offset % 4096 !== 0 ) throw new Error( "Offset value must be a multiple of 4096." );
        if( bytes.length > 4096 ) throw new Error( "Chunk size cannot exceed 4096 bytes." );
        
        return new Promise<ArrayBuffer>( function( accept, reject ) {
        
            const key = offset.ToString();
            const req = t.objectStore( this.fileName ).put( bytes, key );
            
            req.addEventListener( 'error', function( err ) {
                reject( err );
            } );
            
            req.addEventListener( 'success', function() {
                accept();
            } );

        } );
    }

    existsLocally(): Promise<boolean> {
        // TODO: Implement check to see if *any* data for this file exists locally.
    }
}

第 2 部分:AbstractFile

  • 此类包装了基于 IndexedDB 的类和上面的类,并且还使用 .LocalFileStoreLocalFilefetch
  • 当您对文件范围发出读取请求时:
    1. 它首先检查;如果它有必要的块,那么它将检索它们。LocalFileStore
    2. 如果该范围中缺少任何块,则它将回退到使用标头检索请求的范围,并在本地缓存这些块。fetchRange:
  • 当您向文件发出写入请求时:
    • 我实际上还没有实现这一点,但这是留给读者的练习:)
class AbstractFileStore {
    
    private readonly LocalFileStore lfs;

    constructor() {
        this.lfs = LocalFileStore.open();
    }

    openFile( fileName: string, writeable: boolean ): AbstractFile {
        
        return new AbstractFile( fileName, this.lfs.openFile( fileName, writeable ) );
    }
}

class AbstractFile {
    
    private static const BASE_URL = 'https://storage.example.com/'

    constructor(
        public readonly fileName: string,
        private readonly localFile: LocalFile
    ) {
        
    }

    read( offset: BigInt, length: number ): Promise<ArrayBuffer> {

        const anyExistsLocally = await this.localFile.existsLocally();
        if( !anyExistsLocally ) {
            return this.readUsingFetch( chunk, 4096 ); // TODO: Cache the returned data into the localFile store.
        }

        const concat = new Uint8Array( length );
        let count = 0;

        for( const chunkOffset of calculateChunks( offset, length ) ) {
             // TODO: Exercise for the reader: Split `offset + length` into a series of 4096-sized chunks.
            
            const fromLocal = await this.localFile.getChunk( chunk );
            if( fromLocal !== null ) {
                concat.set( new Uint8Array( fromLocal ), count );
                count += fromLocal.length;
            }
            else {
                const fromFetch = this.readUsingFetch( chunk, 4096 );
                concat.set( new Uint8Array( fromFetch ), count );
                count += fromFetch.length;
            }
        }

        return concat;
    }

    private readUsingFetch( offset: BigInt, length: number ): Promise<ArrayBuffer> {
        
        const url = AbstractFile.BASE_URL + this.fileName;

        const headers = new Headers();
        headers.append( 'Range', 'bytes=' + offset + '-' + ( offset + length ).toString() );

        const opts = {
            credentials: 'include',
            headers    : headers
        };

        const resp = await fetch( url, opts );
        return await resp.arrayBuffer();
    }

    write( offset: BigInt, data: ArrayBuffer ): Promise<void> {
        
        throw new Error( "Not yet implemented." );
    }
}

第 3 部分 - 流?

正如上面的类所使用的那样,您可以利用现有功能来创建与 Stream 兼容或类似 Stream 的表示形式 - 当然,它必须是异步的,但 + 使这变得容易。您可以编写一个生成器函数(又名迭代器),它只是异步生成每个块。ArrayBufferArrayBufferasyncawait

评论

0赞 0__ 10/25/2020
感谢您的广泛回答!我确实是沿着这些思路思考的。我发现的另一个正交的东西是idb.filesystem.js库。它不是每个文件有多个键(每个块一个键),而是一遍又一遍地更新对象,然后将其重新放入存储中。乍一看似乎有悖常理,但我读到 blob 不必驻留在内存中,它们确实像文件一样,所以也许浏览器针对此类更新进行了优化。Blob
0赞 Dai 10/25/2020
@0__从技术上讲,浏览器/引擎可以自由地抽象出脚本中的任何内容请注意,a 并不代表磁盘上的文件内存中的任何内容,它只是对任何固定长度的任意二进制数据的抽象(例如,您也可以从 a 中获取 a)。我同意连接对象可能是低效的 - 我建议先进行分析!BlobBlobfetchResponseBlob
0赞 Josh 10/25/2020
包装对 put/add 的调用时,promise 应仅在包含请求的事务完成时解析,而不是请求