Погружаемся в воды Стикса. Часть I: вводное погружение

Эту статью мы обещали уже давно и материала в ней будет много, поскольку нужно дать некоторое начальное понимание того, что такое Styx и что нам в нем так приглянулось. Также в этой статье мы расскажем вам о том, что положено в основу самой популярной библиотеки от LHS в реестре dub.

Эта статья является начало крупной трилогии статей по протоколу Styx.

Начнем с того, что в этой статье почти не будет кода и статья будет посвящена описанию протокола Styx. Протокол Styx (он же протокол 9P) — это сетевой протокол, который был разработан для распределенных систем и разрабатывался он в рамках операционной системы Plan 9. Данный протокол основан на работе с файлами (и кстати сказать, не всегда реальными) и прошел несколько редакций. Текущая версия протокола называется 9P2000, но в операционной системе Inferno (это потомок операционной системы Plan9) он именуется Styx, но никаких улучшений не содержит. В данной статье мы будем расматривать именно 9P2000, иногда будем упоминать его как Styx, но всегда здесь и далее подразумеваться будет один и тот же протокол.

Стоит упомянуть тот факт, что реализацией в пост-юниксовых операционных системах протокола 9P дело не закончилось, так например существует несколько расширений протокола (такие как, например: 9P2000.u, 9P2000.L и другие) и даже реализация протокола как драйвера ядра Linux.

Но мы же хотим вам рассказать о самом протоколе, без его каких-либо специфических расширений протокола или поздних улучшений и хотим вам показать классический вариант.

Как устроен 9P?

Идея протокола 9P до безобразия проста: давайте представим, что наши приложения можно разделить на два вида — это клиенты и серверы, а и те и другие могут совершать примерно одинаковый набор операций над некоторым объектами.

В качестве таких объектов в реализациях протокола выступают файлы; сервер представляется некоторой файловой системой или набором файлов; а клиент — это нечто, что осуществляет операции с файлами и делает это путем обращения к соответствующему серверу. Есть два момента в этой схеме, на которые мы хотели бы обратить ваше внимание: файлы не обязательно должны быть реальными (т.е. фактически приложение может «на лету» генерировать целый комплект структур данных, которые для конечного приложения будут выглядеть как файлы, так устроены так называемые синтетические файлы и синтетические файловые системы) и клиент/сервер могут не знать запущены они на локальном компьютере или же работают на разных сетевых машинах. Оба моменты важны, но они не являются частями протокола 9P, но помогают лучше понимать его реализации.

Клиент и сервер обемениваются между собой потоками байтов, которые называются сообщениями.

Предполагается, что обмен между клиентом и сервером двусторонний, но при этом без всякой синхронизации: т.е. нет никакого приоритета в обработке сообщений и то как будет оно обработано зависит скорее от структуры сообщения, что мы поясним далее. Сообщения в протоколе 9P бывают двух видов: R-сообщения (receive, т.е. сообщения от клиента к серверу) и T-сообщения (transmit, т.е. сообщения от сервера к клиенту) и бывают нескольких базовых типов, у которых в имени добавляется префикс R или T в зависимости от направления передачи.

При этом два парных сообщения (т.е. два сообщения одного типа, но при этом разных видов) называют транзакцией.

Примечание автора. Хм, термин «транзакция» хоть и есть в документации протокола, но не видно было, чтобы активно использовался. Но мы посчитали, что вам это полезно будет знать, особенно если потребуются детали.

Как устроено сообщение протокола?

Но прежде чем, расссказать вам о типах сообщений, необходимо рассказать о том, что представляет собой само сообщение.

Как говорилось ранее, сообщения — это наборы байтов, в которых есть ряд полей. Поля в сообщении могут быть быть фиксированного размера (1, 2, 4 — байтовые поля с порядком байтов Little Endian) и поля с переменным размером (это обычно строковые значения, которые предваряет обычно двухбайтовое поле с описанием размера значения в поле, после чего идет само поле).

Каждое сообщение начинается с так называемого заголовка сообщения и сообщение любого вида (и любого типа) должно содержать заголовок.

Заголовок состоит из следующих полей:

  • 4 байтовое поле с описанием размера всего сообщения (порядок байтов также Little Endian);
  • 1 байтовое поле с описанием типа сообщения (это всегда один байт, с диапазоном значений от 100 до 127, не включая значение 106);
  • 2 байтовое поле с описанием тега сообщения (также порядок записи байтов Little Endian).

И только после заголовка идет уже сама полезная нагрузка сообщения, которая зависит от типа сообщения. А типов сообщения несколько:

  • version (100 / 101) — согласование версий протокола;
  • auth (102 / 103) — обмен авторизационной информацией;
  • attach (104 / 105) — прикрепление файлового дерева;
  • error (107) — сообщение об ошибке;
  • flush (108 / 109) — прерывание сообщения;
  • walk (110 / 111) — смена каталога и передвижение по иерархии файлов;
  • open (112 / 113) — открытие файлового дескриптора;
  • create (114 / 115) — создание файла;
  • read (116 / 117) — считывание данных из файла;
  • write (118 / 119) — запись данных в файл;
  • clunk (120 / 121) — закрытие файлового дескриптора;
  • remove (122 / 123) — удаление файла;
  • stat (124 / 125) — запрос аттрибутов файла;
  • wstat (126 / 127) — смена аттрибутов файла.

Примечание автора. У каждого типа сообщений два числа в скобках — это значение байтового поля тип для R- и T вида сообщения. Но только у одного из типов сообщений этот код только один — это сообщение R-error (т.е. сообщение от сервера к клиенту, с типом error — сообщение об ошибке), поскольку сообщение типа T-error — это нонсенс и такого в принципе быть не может, поэтому парного байтового кода 106 здесь нет, и это надо учитывать при обработке типов сообщений.

То, что содержит сообщение после заголовка регламентируется типом сообщения и вот это мы покажем далее.

Примечание редактора. С этого места и далее, мы используем далее нотацию вида [вид сообщения: T или R]-[тип сообщения (см. список выше)], к примеру, отправленное сервером сообщение об авторизации запишется в такой нотации следующим образом: R-auth или Rauth. Мы будем взаимозаменяемо использовать данные формы записи.

Некоторые типы сообщений и их структура: важные понятия самого протокола

С чего вообще начинается диалог 9P-клиента с 9P-сервером?

Логично предположить, что с установления соединения путем отправки клиентом какого-то особого сообщения.

Данным сообщением здесь выступает T-version. Выглядит это сообщение примерно так:

[size (4)] [T_VERSION (1)] [tag (2)] [msize (4)] [version (n)] 

где:

  • size — размер всего сообщения (в байтах);
  • T_VERSION — однобайтовое значение, указывающее на тип сообщения (в данном случае, это T-version, а его код в виде байта данных — это число 100);
  • tag — тег, уникальный идентификатор для сообщения (поскольку 9P/Styx не синхронный протокол, то с помощью тега можно определить на какое сообщение требуется ответ или какое сообщение идет в обработку, соответственно, тег ответа совпадает с тегом запроса);
  • msize — максимальный размер сообщения (в байтах), который может обработать клиент;
  • version — строковое описание версии протокола и оно переменной длины.

Цифры в скобках, указывают размер поля в байтах: не трудно заметить, что сообщение не может быть меньше 7 байт (поскольку именно столько составляют поля одного только заголовка и некоторые из сообщений могут состоять только из него). Также, нетрудно увидеть что у одного поля в качестве размера прописано значение n, что обозначает, что это поле переменной длины и должно сохраняться в байтовом виде с некоторыми особенностями. К счастью, такая особенность только одна — все поля переменной длины храняться одинаково и универсально, а именно: сначала в порядке Little-Endian идет описание размера поля (два байта с описанием размера в эту величину не входят), а потом идет просто цепочка байтов в порядке Little-Endian.

Для сохранения последовательности изложения в остальных типах сообщений мы будем придерживаться той же нотации (и того же стиля), что и в случае описанного ранее T-version и для полей переменного размера мы будем указывать символ n в описании поля, подразумевая, что поле записывается точно также как и в случае с полем version в сообщении T-version.

Сообщение T-version отправляет клиент на сервер и тут есть ряд нюансов:

  • в случае отправки такого сообщения в качестве тега указывается не ноль, а специальное значение, которое именуется NOTAG и выглядит оно как 16-битное число 0xffff;
  • в качестве значения максимального размера сообщения может фигурировать фактически любое число, но рекомендуется поставить 16-битное значение 65 536 (в описании проткола такого нет, не трудитесь это искать, выявлено чтением кода и эмпирическими попытками) и это же значение вы будете использовать для создания буфера под прием сообщений;
  • в качестве значения для версии в настоящий момент принято значение «9P2000», которое передается в байтовом виде как: [6, 0, 57, 80, 50, 48, 48, 48]

А вот так в байтовом виде выглядит все сообщение T-version:

[19, 0, 0, 0, 100, 255, 255, 0, 0, 1, 0, 6, 0, 57, 80, 50, 48, 48, 48]

и содержит оно всего 19 байт.

В конце статьи мы покажем как получить байтовую репрезентацию этого сообщения.

Ответом сервера является парное сообщение R-version, которое имеет следующую структуру:

[size (4)] [R_VERSION (1)] [tag (2)] [msize (4)] [version (n)]

Сообщение аналогично посланному ответу, но сервер может направить отличный от указанного клиентом максиальный размер сообщения (и клиент должен будет к этому готов и подстроиться под иной размер) и версия также может оказаться другой. При неверной версии, отправленной клиентом, сервер в поле версии укажет «unknown» и на этом связь закончится и клиенту придется заново направлять T-version, но уже с иным описанием версии. Более подробно этот момент раскрывается в мануалах Plan 9 по сообщению с типом version.

Следующее сообщение протокола 9P, которое мы рассмотрим называется T-attach и служит оно для присоединения нужной иерархической части файловой системы сервера. Звучит непонятно, но если говорить коротко, то с помощью T-attach клиент обозначает к какой папке (или говоря шире, к какому ресурсу) он хочет прикрепиться.

Структура сообщения следующая:

[size (4)] [T_ATTACH (1)] [tag (2)] [fid (4)] [afid (4)] [uname (n)] [aname (s)]

Здесь структура содержит уже знакомый нам заголовок и ряд элементов, которые нам не попадались ранее, а именно fid, afid, uname и aname.

Значение которое представляет fid (file indentificator/descriptor, идентификатор файла или его дескриптор) — это 32-битное беззнаковое значение, которое клиент присваивает некоторому файлу, который является на данный момент текущим. Следует понимать, что fid может представлять не только физический файл, но и папку, процесс, вызов процедуры и многое другое. Также следует учитывать то обстоятельство, что в один и тот же момент не должен использоваться один и тот же fid разными клиентами, поскольку общение между клиентом и сервером идет на базе fid (fid выбирается клиентом и придумывается им самостоятельно).

Значение, представленное в afid тесно связано с процессом аутентификации и сообщением T-auth. Дело в том, что если сервером предусмотрена какая-то аутентификация, то перед отправкой cообщения T-attach отправляется сообщение T-auth, в котором afid является идентификатором аутентификационного файла для исполнения механизма аутентификации. Метод аутентификации не является частью протокола 9P и его реализуют самостоятельно.

Однако, если аутентификация не требуется, то сервер ответит сообщением R-error (в котором в дополнение к заголовку будет строка с описанием текста ошибки, ограниченного по длине до размера самого сообщения).

Если же сам клиент не хочет проводить аутентификацию, afid указывается специальное значение NOFID, которое равно 32-битному значению с величиной 0xffffffff. Таким образом, в afid либо находиться значение NOFID, либо значение, которое подавалось как afid в сообщении T-auth. Подробности тонких моментов можно найти в описании типа сообщения auth/attach на странице мануалов Plan 9.

Следующие значения — это uname и aname и их интерпретация довольно проста, поскольку обе этих величины — обычные строковые значения, которые соответствуют имени пользователя и имени файлового дерева, к которому идет подсоединение. Поскольку, оба эти значения строковые, то они записываются как поля переменной длины, раскладываются на байты и в них (как и в любых других строковых значениях) нет нулевого символа.

Типов сообщений в протоколе 9P/Styx всего 27 (это 13 сообщений * 2 их вида + одно сообщение типа error) все их подробно не будем, а только покажем их структуру, поскольку не все из этих сообщений могут быть использованы и детально некоторые из них мы рассмотрим при реализации примеров в двух последующих статьях по погружению в протокол Styx.

Структура сообщений 9P/Styx с использованием упомянутых ранее ограничений по нотации (ранее описанные типы сообщений также присутствуют):

[size (4)] [T_VERSION (1)] [tag (2)] [msize (4)] [version (n)]
[size (4)] [R_VERSION (1)] [tag (2)] [msize (4)] [version (n)]
[size (4)] [T_AUTH (1)] [tag (2)] [afid (4)] [uname (n)] [aname (n)]
[size (4)] [R_AUTH (1)] [tag (2)] [aqid (13)]
[size (4)] [R_ERROR (1)] [tag (2)] [ename (n)]
[size (4)] [T_FLUSH (1)] [tag (2)] [oldtag (2)]
[size (4)] [R_FLUSH (1)] [tag (2)]
[size (4)] [T_ATTACH (1)] [tag (2)] [fid (4)] [afid (4)] [uname (n)] [aname (n)]
[size (4)] [R_ATTACH (1)] [tag (2)] [qid (13)]
[size (4)] [T_WALK (1)] [tag (2)] [fid(4)] new[fid(4)] nwname (2)] nwname*(wname[s])
[size (4)] [R_WALK (1)] [tag (2)] [nwqid (2)] [wqid (13)])
[size (4)] [T_OPEN (1)]  [tag (2)] [fid (4)] [mode (1)]
[size (4)] [R_OPEN (1)] [tag (2)] [qid (13)] [iounit (4)]
[size (4)] [T_CREATE (1)]  [tag (2)] [fid (4)] [name (n)] [perm (4)] [mode (1)]
[size (4)] [R_CREATE (1)] [tag (2)] [qid (13)] [iounit (4)]
[size (4)] [T_READ (1)] [tag (2)] [fid(4)] [offset (8)] [count (4)]
[size (4)] [R_READ (1)] [tag (2)] [count (4)] [data (n)]
[size (4)] [T_WRITE (1)] [tag (2)] [fid(4)] [offset (8)] [count (4)] [data (n)]
[size (4)] [R_WRITE (1)] [tag (2)] [count (4)]
[size (4)] [T_CLUNK (1)] [tag (2)] [fid (4)]
[size (4)] [R_CLUNK (1)] [tag (2)]
[size (4)] [T_REMOVE (1)] [tag (2)] [fid (4)]
[size (4)] [R_REMOVE (1)] [tag (2)]
[size (4)] [T_STAT (1)] [tag (2)] [fid (4)]
[size (4)] [R_STAT (1)] [tag (2)] [stat (n)]
[size (4)] [T_WSTAT (1)] [tag (2)] [fid (4)] [stat (n)]
[size (4)] [R_WSTAT (1)] [tag (2)]] 

Короткий анонс библиотеки и небольшой пример (а также описание еще одного важного понятия в протоколе)

Прежде чем перейти к описанию кода по трансляции структур протокола в байтовый вид и небольшому анонсу самой популярной (согласно реестру dub) из всех библиотек LightHouse Software, мы хотели бы вам рассказать про специфический идентификатор в протоколе. Этот идентификатор называется qid и встречается во многих типах сообщений, которые поступают от сервера и встречается в двух вариантах (с разным смыслом варианты, но структура одинакова) — qid и aqid.

Данный идентификатор появляется только в серверных сообщениях и вот почему. Qid — это уникальный 13-битный идентификатор файла, который присваивается файлу сервером. Файлы, с точки зрения сервера (в пределах одной и той же файловой иерархии или одного и того же дерева файлов), одинаковы тогда и только тогда, когда у них одинаков qid. Более того, этот идентификатор имеет собственную внутреннюю структуру из байтовых полей:

  • 1 байтовое поле с типом qid (qid type), которое объясняет чем является файл: папкой, файлом аутентификации, файлом с записью в его конец, обычным файлом и т.п;
  • 4 байтовое поле, которое называется версией qid (qid version) и котрое является 32-битным беззнаковым числом, которое каждый раз увеличивается на единицу, если файл как-то модифицировался (включая смену аттрибутов). Обычно в качестве этого значения некоторые реализации прописывают в это поле unix timestamp последней модификации.
  • 8 байтовое поле, которое называется путем qid (qid path) и это уже 64-битное беззнаковое целое, которое является уникальным идентификатором для файла. Самое интересное, что оно настолько уникальное, что если файл будет удален на сервере и его заменит новый файл с тем же именем, то путь qid у этих двух файлов разный! В некоторых реализациях этот идентификатор генерируется как 64-битный хэш.

Мы упомянули этот идентификатор, поскольку он играет важную роль в последующих наших статьях (там мы поговорим об этом подробнее, поскольку он играет важную роль в реализации сервера), но сейчас перейдем к единственному практическому примеру в сегодняшней статье.

Дело в том, что наша организация разработала первую библиотеку для D, которая содержит в себе реализацию низкоуровневых абстракций протокола 9P/Styx, средства для создания и декодирования из байтового потока сообщений 9P, а также небольшую коллекцию вспомогательных инструментов для облегчения работы с протоколом. Называется наша библиотека styx2000 и на полную ее разработку (включая и документацию) у нас ушло около 3 месяцев, а на подготовку материала — около 2 месяцев. И хоть мы нигде не упоминали нашу библиотеку, но внезапно для нас стало сюрпризом то, что она скачивалась значительно чаще, чем наша самая известная библиотека — ppmformats.

Именно с помощью этой библиотеки нам удалось получить байтовое представление сообщения T-version в 9P/Styx и сделали это мы таким образом:

#!/usr/bin/env dub
/+ dub.sdl:
	dependency "styx2000" version="~>1.0.0"
+/

import std.stdio;

import styx2000.protoconst;
import styx2000.protobj;
import styx2000.protomsg;

void main()
{
   Size size = new Size;
   Type type = new Type(STYX_MESSAGE_TYPE.T_VERSION);
   Tag tag = new Tag;
   Msize msize = new Msize(65536);
   Version vers = new Version("9P2000");
   encode(cast(StyxObject[]) [
	size, type, tag, msize, vers
   ]).writeln;
}

Мы не зря в этой статье вставили структуру сообщений 9P/Styx, поскольку в библиотеке styx2000 сделаны классы, которые почти один в один совпадают с именованиями полей в сообщениях, что позволяет просто создать несколько нужных объектов (все описания их находятся в модуле styx2000.protobj), использовать для их инициализации константы по умолчанию или импортировать их модуля styx2000.protoconst (для типов наподобие Fid значение по умолчанию STYX_NOFID, значение которого мы уже описывали, для остальных — нулевые значения) и собрать из них сообщение (делается через приведение к единому интерфейсу StyxObject, который поддерживает два универсальных метода — pack/unpack, соответственно упаковку/распаковку в байтовом виде). После чего остается лишь запустить кодирование в байтовый вид, что достигается применением универсальной функции encode, которая не только выполнит кодирование в байты, но и корректно рассчитает размер самого сообщения, записав его в size (обратили внимание, что размер сообщения в начале ноль?!).

На настоящий момент библиотека styx2000 фактически закончена и в нее будут разве что добавляться новые возможности (модуль styx2000.extrautil) и исправляться ошибки, также планируется дорабатывать документацию, так как этого требует лицензия ESL 1.0 под которой выпущена данная библиотека.

Что дальше? Как обычно, мы оставляем подборку ссылок, которая помогла нам разобраться с протоколом 9P/Styx, еще вы можете начать с примеров, которые мы привели на странице библиотеки на GitHub.

Ссылки на ресурсы по 9P/Styx

aquaratixc

Программист-самоучка и программист-любитель

Добавить комментарий