WebKit 的资源磁盘缓存机制

1,832 阅读8分钟

前言

最近项目在调用离线缓存的相关内容,有一种方案通过 WKURLSchemeHandler 拦截 HTML 请求实现 HTML 的本地缓存,其他资源文件则采用 WebKit 自带的 HTTP 资源磁盘缓存机制来实现本地离线缓存(限制条件是 H5 中的资源加载都必须采用绝对地址)。该方案涉及到 2 个缓存管理,HTML 缓存管理 和 WebKit 资源缓存管理, HTML 缓存管理是我们自己代码实现的,可以做到对缓存内容百分之百的控制,而 WebKit 的资源磁盘缓存管理依赖于 WebKit 的底层实现逻辑,对顶层业务是一个黑盒,不受控制。在网上查找了相关资料后,对于WebKit 的磁盘缓存有了一定的了解。

按照网上资料的说法, WebKit 的缓存分成 3 种,page cache、memory cache 和 disk cache,目前个人比较关心 disk Cache 的相关内容,所以下文中的缓存,如果没有特别说明都默认代指 disk Cache

在 深入剖析WebKit 的 5.9.3 章节中,有下面的一段关于 disk cache 的描述

根据 HTTP 的头信息来设置缓存。属于持久化缓存存储,重新打开浏览器还能节省下载的流量和时间浪费。使用的也是 LRU 算法来控制缓存磁盘空间大小。具体实现是由平台相关代码来完成

  • HTTP 的头信息来设置缓存,实际上就是 HTTP缓存,网上关于 HTTP 缓存的文章很多,这里不详细展开
  • LUR 算法控制磁盘空间大小

这里没有说明具体磁盘空间的大小限制并且在网上也没找到相关的资料,带着这小小的疑惑和对 disk cache 详细实现机制的好奇简单阅读了下源码。

磁盘存储空间大小

在 WebKit/NetworkProcess/cache/NetworkCache.cpp 文件中,找到了capacity大小计算的相关代码

static size_t computeCapacity(CacheModel cacheModel, const String& cachePath)
{
    unsigned urlCacheMemoryCapacity = 0;
    uint64_t urlCacheDiskCapacity = 0;
    if (auto diskFreeSize = FileSystem::volumeFreeSpace(cachePath)) {
        // As a fudge factor, use 1000 instead of 1024, in case the reported byte
        // count doesn't align exactly to a megabyte boundary.
        *diskFreeSize /= KB * 1000;
        calculateURLCacheSizes(cacheModel, *diskFreeSize, urlCacheMemoryCapacity, urlCacheDiskCapacity);
    }
    return urlCacheDiskCapacity;
}


void calculateURLCacheSizes(CacheModel cacheModel, uint64_t diskFreeSize, unsigned& urlCacheMemoryCapacity, uint64_t& urlCacheDiskCapacity)
{
    switch (cacheModel) {
    case CacheModel::DocumentViewer: {
        
        // ... 省略内存 capacity 的相关代码
        
        // Disk cache capacity (in bytes)
        urlCacheDiskCapacity = 0;

        break;
    }
    case CacheModel::DocumentBrowser: {
    
        // ... 省略内存 capacity 的相关代码
 
        // Disk cache capacity (in bytes)
        if (diskFreeSize >= 16384)
            urlCacheDiskCapacity = 75 * MB;
        else if (diskFreeSize >= 8192)
            urlCacheDiskCapacity = 40 * MB;
        else if (diskFreeSize >= 4096)
            urlCacheDiskCapacity = 30 * MB;
        else
            urlCacheDiskCapacity = 20 * MB;

        break;
    }
    case CacheModel::PrimaryWebBrowser: {
        
        // ... 省略内存 capacity 的相关代码

        // Disk cache capacity (in bytes)
        if (diskFreeSize >= 16384)
            urlCacheDiskCapacity = 1 * GB;
        else if (diskFreeSize >= 8192)
            urlCacheDiskCapacity = 500 * MB;
        else if (diskFreeSize >= 4096)
            urlCacheDiskCapacity = 250 * MB;
        else if (diskFreeSize >= 2048)
            urlCacheDiskCapacity = 200 * MB;
        else if (diskFreeSize >= 1024)
            urlCacheDiskCapacity = 150 * MB;
        else
            urlCacheDiskCapacity = 100 * MB;

        break;
    }
    default:
        ASSERT_NOT_REACHED();
    };
}

从代码中可以看到, disk cache capacity 的大小限制实际上是由 cacheModel 和 diskFreeSize 这 2 个变量决定的。

cacheModel 是来自 NetworkProcess 的 cacheModel(),在 Cache::open 中获取传入,如下代码所示

RefPtr<Cache> Cache::open(NetworkProcess& networkProcess, const String& cachePath, OptionSet<CacheOption> options, PAL::SessionID sessionID)
{
    
    /// ... 省略无关代码
    auto capacity = computeCapacity(networkProcess.cacheModel(), cachePath);
  
    /// ... 省略无关代码
    return adoptRef(*new Cache(networkProcess, cachePath, storage.releaseNonNull(), options, sessionID));
}
而 NetworkProcess 的 cacheModel,经过一系列的回溯后,发现是来自 LegacyGlobalSettings,默认值是 CacheModel::PrimaryWebBrowser。具体的传递路径如下:


======== UIProcess ========

[WKContentView initWithFrame:processPool:configuration:webView:]

[WKContentView _commonInitializationWithProcessPool:configuration:]

WebProcessPool::createWebPage

pageConfiguration->setWebsiteDataStore(WebKit::WebsiteDataStore::defaultDataStore().ptr()):

WebsiteDataStore 中有一个 networkProcess(),第一次被调用时,会调用 networkProcessForSession 创建一个 NetworkProcessProxy

NetworkProcessProxy::ensureDefaultNetworkProcess

NetworkProcessProxy::create()

sendCreationParametersToNewProcess():将创建参数(NetworkProcessCreationParameters)发送给新创建的网络进程,NetworkProcessCreationParameters 包含了 cacheModel,而 cacheModel 是源自 LegacyGlobalSettings::singleton().cacheModel(), 值也是 CacheModel::PrimaryWebBrowser

send(Messages::NetworkProcess::InitializeNetworkProcess(parameters), 0): 发送初始化参数给网络进程

======== NetworkProcess ========

NetworkProcess::initializeNetworkProcess 中会调用 setCacheModel(parameters.cacheModel)

目前个人在代码中,没有找到其他的调用对 NetworkProcess 的 cacheModel 进行修改,由此认为 cacheModel 的值为 CacheModel::PrimaryWebBrowser

diskFreeSize 是来自于 FileSystem::volumeFreeSpace(cachePath) 的计算结果,而 FileSystem::volumeFreeSpace 的实现如下

std::optional<uint64_t> volumeFreeSpace(const String& path)
{
    std::error_code ec;
    auto spaceInfo = std::filesystem::space(toStdFileSystemPath(path), ec);
    if (ec)
        return std::nullopt;
    return spaceInfo.available;
}

个人理解 filesystem::space 获取到的手机剩余的存储空间

自己用真机测试了下,手机系统显剩余容量 33GB, filesystem::space 获取到 available 的值是 16GB,而手机系统显示剩余容量是 446 GB 时,filesystem::space 获取到 available 的值是 390GB

总的来说,当手机剩余磁盘容量充足时(实测是大于 30GB)时,disk cache 最多会被分配大约 1GB 的存储空间,分配的存储空间随着手机剩余磁盘空间的降低而同步减少,当手机容量不足时,最少也会被分配 100M 的存储空间

NetworkCache 的整体架构

image.png

存储流程

当网络资源加载完毕后,会触发 NetworkResourceLoader 的 didFinishLoading 方法,在该方法中,会尝试对网络请求的数据进行缓存,具体代码如下:

void NetworkResourceLoader::didFinishLoading(const NetworkLoadMetrics& networkLoadMetrics)
{
   
   /// ... 省略

    tryStoreAsCacheEntry();

   /// ... 省略

}

void NetworkResourceLoader::tryStoreAsCacheEntry()
{
   /// ... 省略一些判断逻辑,用于判断是否进行缓存
   
    m_cache->store(m_networkLoad->currentRequest(), m_response, WTFMove(m_bufferedDataForCache), [loader = Ref { *this }](auto& mappedBody) mutable {
        /// ... 省略
    });
}

如果网络请求的 response 是需要进行缓存的,则最终会调用到 Cache::store 方法,大致代码如下:

std::unique_ptr<Entry> Cache::store(const WebCore::ResourceRequest& request, const WebCore::ResourceResponse& response, RefPtr<WebCore::SharedBuffer>&& responseData, Function<void(MappedBody&)>&& completionHandler)
{
    /// ... 忽略日志和断言

    StoreDecision storeDecision = makeStoreDecision(request, response, responseData ? responseData->size() : 0);
    
    if (storeDecision != StoreDecision::Yes) {
        /// ... 忽略错误处理
        return nullptr;
    }

    auto cacheEntry = makeEntry(request, response, WTFMove(responseData));
    auto record = cacheEntry->encodeAsStorageRecord();

    m_storage->store(record, [protectedThis = Ref { *this }, completionHandler = WTFMove(completionHandler)](const Data& bodyData) mutable {
        MappedBody mappedBody;
        /// ... 忽略对 mappedBody 的处理
        if (completionHandler)
            completionHandler(mappedBody);
    });

    return cacheEntry;
}

在 Cache::store 中,首先会调用 makeStoreDecision 方法,判断是否需要进行本地缓存,大致是判断 request 是不是 Get 请求,response 的 header 中是否明确表示不缓存等。

在决定需要进行缓存后,会根据 request、response 和 responseData 创建一个 Entry 的实例 cacheEntry,再调用 cacheEntry 的 encodeAsStorageRecord() 获取到实际进行存储的 Record 对象(主要存储 response 的 header、 respone data 和一些时间信息)

最后,调用 Storage 的 store 将 record 存储到磁盘中

void Storage::store(const Record& record, MappedBodyHandler&& mappedBodyHandler, CompletionHandler<void(int)>&& completionHandler)
{
    /// ... 忽略日志和断言
    if (!m_capacity)
        return;

    auto writeOperation = makeUnique<WriteOperation>(*this, record, WTFMove(mappedBodyHandler), WTFMove(completionHandler));
    m_pendingWriteOperations.prepend(WTFMove(writeOperation));

    // Add key to the filter already here as we do lookups from the pending operations too.
    addToRecordFilter(record.key);

    bool isInitialWrite = m_pendingWriteOperations.size() == 1;
    if (!isInitialWrite || (m_synchronizationInProgress && m_mode == Mode::AvoidRandomness))
        return;

    m_writeOperationDispatchTimer.startOneShot(m_initialWriteDelay);
}


void Storage::dispatchWriteOperation(std::unique_ptr<WriteOperation> writeOperationPtr)
{
    /// ... 忽略日志和断言

    auto& writeOperation = *writeOperationPtr;
    m_activeWriteOperations.add(WTFMove(writeOperationPtr));

    // This was added already when starting the store but filter might have been wiped.
    addToRecordFilter(writeOperation.record.key);

    backgroundIOQueue().dispatch([this, &writeOperation] {
        auto recordDirectorPath = recordDirectoryPathForKey(writeOperation.record.key);
        auto recordPath = recordPathForKey(writeOperation.record.key);

        FileSystem::makeAllDirectories(recordDirectorPath);

        ++writeOperation.activeCount;

        bool shouldStoreAsBlob = shouldStoreBodyAsBlob(writeOperation.record.body);
        auto blob = shouldStoreAsBlob ? storeBodyAsBlob(writeOperation) : std::nullopt;

        auto recordData = encodeRecord(writeOperation.record, blob);

        auto channel = IOChannel::open(recordPath, IOChannel::Type::Create);
        size_t recordSize = recordData.size();
        channel->write(0, recordData, WorkQueue::main(), [this, &writeOperation, recordSize](int error) {
            // On error the entry still stays in the contents filter until next synchronization.
            m_approximateRecordsSize += recordSize;
            finishWriteOperation(writeOperation, error);

            LOG(NetworkCacheStorage, "(NetworkProcess) write complete error=%d", error);
        });
    });
}

在 Storage::store 方法中,会先判断 m_capacity 是否大于 0,而从前面可以知道,在如果没有做特别的设置,m_capacity 最小值也会有 100MB 大小。由此可以推断出,主要 response 是可以缓存的,就一定会尝试将 response 存储到磁盘中,即使此时实际磁盘的存储大小已经大于 m_capacity

紧接着,会创建一个写入操作,并派发到写入操作队列中,最终的写入操作是在 dispatchWriteOperation 执行, 在这里会判断下 response 的 body 是否会超过某个阀值(pageSize的大小,在 iOS 中是 16kB),如果超过了,则会将 body 生成一个 Blob,然后通过 BlobStorage 将 body 独立存储一个文件。如果没有超过,则作为 record 的一部分一起存储。

由于 response 的 body 有可能独立存储,所当多个 reponse 返回同一个 body 时,为了节省存储空间只会存储一个 body,因此相对应的会出现多个 record 共用一个 blob 的情况。如下图所示:

image.png

在真机中,存储的路径是在 Library/Caches/Webkit/NetworkCache 中,在模拟器中,存储路径是在 {模拟器路径}/Library/Caches//Webkit/NetworkCache

磁盘空间清理流程

在每次文件写入结束后,finishWriteOperation方法就会被调用,接着就会调用 shrinkIfNeeded() 判断是否需要清理磁盘空间,如果存储内容大小超过了容量,则调用shrink进行磁盘清理操作,具体代码如下:

void Storage::shrinkIfNeeded()
{
    ASSERT(RunLoop::isMain());

    // Avoid randomness caused by cache shrinks.
    if (m_mode == Mode::AvoidRandomness)
        return;

    if (approximateSize() > m_capacity)
        shrink();
}

void Storage::shrink()
{

    if (m_shrinkInProgress || m_synchronizationInProgress)
        return;
    m_shrinkInProgress = true;

    backgroundIOQueue().dispatch([this, protectedThis = Ref { *this }] () mutable {
        auto recordsPath = this->recordsPathIsolatedCopy();
        String anyType;
        traverseRecordsFiles(recordsPath, anyType, [this](const String& fileName, const String& hashString, const String& type, bool isBlob, const String& recordDirectoryPath) {
            if (isBlob)
                return;

            auto recordPath = FileSystem::pathByAppendingComponent(recordDirectoryPath, fileName);
            auto blobPath = blobPathForRecordPath(recordPath);

            auto times = fileTimes(recordPath);
            unsigned bodyShareCount = m_blobStorage.shareCount(blobPath);
            auto probability = deletionProbability(times, bodyShareCount);

            bool shouldDelete = randomNumber() < probability;

            if (shouldDelete) {
                FileSystem::deleteFile(recordPath);
                m_blobStorage.remove(blobPath);
            }
        });

        RunLoop::main().dispatch([this, protectedThis = WTFMove(protectedThis)] {
            m_shrinkInProgress = false;
            // We could synchronize during the shrink traversal. However this is fast and it is better to have just one code path.
            synchronize();
        });
        
    });
}

在 shrink中,会遍历所有的 record,根据 record 的对应 blob 的被引用次数、修改时间和创建时间,计算出一个可能值(probability),然后再将 probability 和一个随机数进行对比,如果大于随机数,则将 record 删除。这里的关键是 deletionProbability 这个方法的具体实现,如下所示

static double computeRecordWorth(FileTimes times)
{
    auto age = WallTime::now() - times.creation;
    // File modification time is updated manually on cache read. We don't use access time since OS may update it automatically.
    auto accessAge = times.modification - times.creation;

    // For sanity.
    if (age <= 0_s || accessAge < 0_s || accessAge > age)
        return 0;

    // We like old entries that have been accessed recently.
    return accessAge / age;
}

static double deletionProbability(FileTimes times, unsigned bodyShareCount)
{
    static const double maximumProbability { 0.33 };
    static const unsigned maximumEffectiveShareCount { 5 };

    auto worth = computeRecordWorth(times);

    // Adjust a bit so the most valuable entries don't get deleted at all.
    auto effectiveWorth = std::min(1.1 * worth, 1.);

    auto probability =  (1 - effectiveWorth) * maximumProbability;

    // It is less useful to remove an entry that shares its body data.
    if (bodyShareCount)
        probability /= std::min(bodyShareCount, maximumEffectiveShareCount);

    return probability;
}

个人觉得是一个类 LRU 算法,虽然最近越少被使用计算出来的 probability 值越大,越有可能被删除掉,但是由于是和一个随机数做对比,所以应该还是有一定的偶然性。