Дельта-обновления и манифесты
Основы
Чтобы подключиться к серверу, нам нужно иметь все файлы контента, которые указывает сервер. Это все текстуры, прототипы, сборки кода и всё остальное, что нужно загрузить движку.
Сервер предоставляет нам следующую информацию о необходимой ему сборке. Эта информация передаётся через /info в HTTP status API. В настоящее время серверы предоставляют следующую информацию.
- Fork ID и версия: используются исключительно как эвристика для определения того, какие старые версии вытеснять из базы данных контента лаунчера в первую очередь. Не используются ни для чего критичного с точки зрения безопасности (всегда проверяются криптографическими хешами фактического контента игры). Fork ID, по сути, является «именем кодовой базы», а версия форка является идентификатором версии.
- Версия движка: версия движка, которую нужно использовать при запуске этого и разрешении модулей движка.
- Информация о zip: используется для загрузок контента на основе zip:
- URL скачивания zip: URL, по которому скачивается zip-файл, содержащий весь контент сервера.
- Хеш SHA-2561 для zip: ожидаемый хеш SHA-256 указанного zip-файла.
- Информация о манифесте: используется для загрузок контента на основе манифеста:
- URL манифеста: URL, по которому загружается манифест контента.
- Хеш BLAKE2b манифеста: хеш манифеста.
- URL скачивания манифеста: URL, по которому запрашиваются загрузки контента.
Существует два основных режима работы лаунчера при управлении и скачивании контента: на основе zip и на основе манифеста. Режим на основе zip является устаревшей моделью. Он не поддерживает никаких дельта-обновлений и существует для обратной совместимости и простоты. Режим на основе манифеста может поддерживать дельта-обновления (то есть скачивание только изменённых файлов).
Да, хеши zip являются SHA-256, все остальные хеши являются 256-битным BLAKE2b. BLAKE2b гораздо быстрее вычисляется, и в реальных сценариях это накапливается, поэтому он используется для нового. Zip использует SHA-256 для обратной совместимости.
ContentDB лаунчера
Файлы контента игры, отслеживаемые лаунчером, хранятся в виде блобов в базе данных SQLite в режиме WAL. Идентичность файлов сравнивается исключительно по хешу BLAKE2b содержимого, и при экономии места может опционально применяться сжатие ZSTD. Эти блобы отслеживаются в таблице Content.
Каждая «версия» контента, которую лаунчер скачал и сейчас хранит, записывается в таблицу ContentVersion. Весь ContentManifest (= список файлов) хранится для каждой версии, содержа путь к файлу ресурса и указывая, какой блоб Content использовать. Эти ContentVersion также всегда хранят хеш манифеста (см. ниже) для идентификации своего контента. Они могут опционально хранить хеш SHA-256 zip для обратной совместимости с загрузками zip.
Индексируя фактические блобы ресурсов по хешу BLAKE2b и храня пути только для каждого манифеста, мы можем дедуплицировать блобы ресурсов как в пределах одной версии, так и между множеством разных версий и даже форков. Ура, экономия места!
Мы также отслеживаем, какая версия robust и модули (включая их точную версию) необходимы для каждой версии, в таблице ContentEngineDependency. Это немного неудобно для лаунчера, потому что фактическая версия контента (в первую очередь определяемая неким хешем) на самом деле не связана с используемой версией движка: в конце концов, версия движка указывается сервером через status API. Она не содержится в загрузке контента (но список модулей для использования содержится, поскольку он хранится в манифесте контента). Решение здесь состоит в том, что мы заставляем лаунчер дублировать версию и манифест, если одна и та же версия контента попытается использоваться с другой версией движка. При этом нам также приходится заново разрешать версии модулей.
Загрузки манифеста
В режиме на основе манифеста сервер сообщает «манифест контента», который содержит хеши всех файлов в файлах контента и их пути. Затем этот манифест, в свою очередь, снова хешируется, и этот хеш является окончательным источником истины для идентификации определённого пакета контента.
Этот манифест скачивается и сравнивается при попытке подключиться. Если у нас нет этого манифеста, мы загружаем манифест с сервера. Затем мы находим все блобы, которых у нас ещё нет (по хешу), и запрашиваем их у сервера через URL скачивания манифеста, который он сообщает.
Эта загрузка манифеста использует простой бинарный формат запроса/ответа поверх HTTP POST2 из соображений производительности. На момент написания у SS14 около 13 тыс. файлов в его пакете ресурсов (я хочу сократить количество RSI, что помогло бы, но всё же). Делать что-то вроде отдельных HTTP-запросов для каждого отдельного файла было бы смехотворно, даже с конвейеризацией запросов или любым другим безумием, которое у вас может быть. Бинарный протокол описан ниже.
В идеале здесь использовалось бы что-то вроде HTTP QUERY вместо POST, поскольку это просто GET с телом запроса. Однако на момент написания QUERY ещё не стандартизирован и, вероятно, полностью не поддерживается нигде.
Формат манифеста
Формат файлов манифеста контента следующий:
Robust Content Manifest 1
<uppercase hex BLAKE2b 256-bit hash> <file path>
<uppercase hex BLAKE2b 256-bit hash> <file path>
<uppercase hex BLAKE2b 256-bit hash> <file path>
<uppercase hex BLAKE2b 256-bit hash> <file path>
...
Да, всё настолько просто. Пожалуйста, одиночные переводы строк, без всякого CRLF. Заголовок сверху является просто заголовком версии. Пожалуйста, завершающий перевод строки. Сортируйте записи по полному пути к файлу в лексикографическом порядке.
Код на Python для генерации этого манифеста из zip-файла:
import codecs
import hashlib
import io
import zipfile
def generate_manifest_hash(file: str) -> str:
zip = zipfile.ZipFile(file)
infos = zip.infolist()
infos.sort(key=lambda i: i.filename)
bytesIO = io.BytesIO()
writer = codecs.getwriter("UTF-8")(bytesIO)
writer.write("Robust Content Manifest 1\n")
for info in infos:
if info.filename[-1] == "/":
continue
bytes = zip.read(info)
hash = hashlib.blake2b(bytes, digest_size=32).hexdigest().upper()
writer.write(f"{hash} {info.filename}\n")
manifestHash = hashlib.blake2b(bytesIO.getbuffer(), digest_size=32)
return manifestHash.hexdigest().upper()
Хеш BLAKE2b этого файла используется как единственный достоверный идентификатор для определения идентичности пакета ресурсов.
Детали протокола запроса на скачивание
Ладно, да, это немного сложнее, чем простой HTTP POST.
Клиент выполнит HTTP GET манифеста контента по URL, указанному сервером. Сервер должен3 поддерживать сжатие контента через HTTP Accept-Encoding/Content-Encoding: лаунчер принимает zstd, brotli, gzip и deflate. Рекомендуется ZSTD, потому что это по-настоящему современная технология. Манифест представляет собой просто текстовую конструкцию выше.
Чтобы выполнить собственно скачивание контента, мы сначала делаем HTTP OPTIONS по URL, предоставленному сервером. Этот OPTIONS обязан вернуть заголовки ответа X-Robust-Download-Min-Protocol и X-Robust-Download-Max-Protocol, которые лаунчер сможет использовать в будущем для обратной и прямой совместимости. Вероятно, я это переусложняю.
Текущей «версией протокола» является 1.
После этого HTTP OPTIONS лаунчер отправит POST-запрос на тот же URL. Запрос содержит заголовок X-Robust-Download-Protocol с текущей версией протокола, чтобы сервер мог его понять. В теле запроса находится полный список файлов для запроса, согласно протоколу ниже. Content-Type должен быть application/octet-stream. Клиент также снова отправляет Accept-Encoding, чтобы разрешить сжатие всего тела HTTP-ответа: сервер может последовать этому, если сочтёт компромисс оправданным.4
Тело запроса представляет собой просто последовательность 32-битных LE 0-индексов в манифест контента, каждый из которых указывает блоб в манифесте для скачивания.5 Индексы не должны запрашиваться дважды в одном запросе.
Тело ответа сложнее и в настоящее время имеет следующий формат:
<stream header>:
int32 LE stream header flags field:
bit 0 (pre-compress): if set, stream blobs are individually pre-compressed with ZSTD.
FOR every file in the request body:
<file header>:
int32 LE blob size: Uncompressed size of the file blob
IF pre-compress is set:
int32 LE compressed size: Compressed size of the blob.
If zero, the blob is not compressed and the uncompressed size should be used instead.
<file contents>:
N bytes for the file contents, see file header above for size
Смотрите, какой я важный, использую язык в стиле RFC. 4: Потоковое сжатие снижает использование пропускной способности ценой повышенной нагрузки на ЦП сервера и лаунчера во время передачи. Обычно, если сервер использует потоковое сжатие, он не использует отдельно сжатые блобы. Из-за этого после завершения загрузки блобы также будут храниться в ContentDB лаунчера менее компактно. 5: Это индексы в манифест, а не напрямую сырые хеши блобов, потому что хеши заняли бы слишком много пропускной способности при отправке.
Бинарный формат
Загрузки на основе zip
Режим на основе zip частично задуман как менее сложный и является более старой моделью обновления. Сервер указывает zip-файл и хеш SHA256 для него. Этот хеш проверяется и сравнивается, и при необходимости скачивается zip-файл. Локальные файлы загружаются из zip-файла в базу данных контента лаунчера.
Хеш манифеста генерируется и сохраняется автоматически. Это делается для обеспечения прямой совместимости с методами обновления на основе манифеста.
Техники дельта-обновления
Дельта внутри файла (диффы): здесь мы могли бы использовать zstd с его поддержкой дельтования файлов.
Идеи на будущее
Прямо сейчас манифесты довольно большие, даже в сжатом виде. Во многом это сводится к тому, что 32-байтовые хеши по своей природе несжимаемы, а файлов очень много.
Более продвинутая система могла бы лучше использовать деревья Меркла, чтобы сократить объём манифеста, который нужно отправить. Это позволило бы небольшим обновлениям YAML-файлов скачиваться в объёме однозначных килобайт вместо текущих 450+ КиБ, без необходимости в явной системе дельт между версиями.
Veloren предоставляет свои инкрементальные обновления, используя HTTP-запросы диапазона на стандартных CDN. Вероятно, это менее эффективно с точки зрения чистой пропускной способности, но возможность использовать обычные CDN нельзя недооценивать, поскольку наша система требует активного сервера. Это то, что стоит изучить на будущее, если нам понадобится масштабировать производительность.