Обложка статьи «Качественная архитектура ПО на примере концепции Linux «всё есть файл»»

Качественная архитектура ПО на примере концепции Linux «всё есть файл»

Адаптированный перевод статьи «Good Code Design From Linux/Kernel»

В статье показано, как в Linux/FFmpeg организована кодовая база на C с учётом расширяемости, которая работает так, будто в C есть полиморфизм. Вы увидите, как концепция Linux «всё — файл» работает на уровне исходного кода, а также как FFmpeg позволяет быстро и легко добавлять поддержку новых форматов и кодеков.

 

Качественный дизайн ПО — введение

В процессе работы над кодом программисты регулярно сталкиваются с тем, что качественный дизайн кода окупается впоследствии при усложнении продукта. Для создания полезного и легко поддерживаемого в долгосрочной перспективе ПО разработчики подбирают определённые шаблоны и объединяют их в абстракции, и похоже, что разработчики Linux и FFmpeg поступили именно так.

При разработке программ создаются структуры данных и определяются их зависимости и поведение. То, как они построены и связаны между собой, можно рассматривать как дизайн/архитектуру ПО.

Предположим, что мы разрабатываем фреймворк для обработки видео- и аудиофайлов. Кодеки AV1, H264, HEVC и AAC производят некоторые идентичные операции с данными, и если мы разработаем некоторую обобщённую абстракцию, включающую эти операции, мы сможем использовать эту абстракцию вместо того, чтобы реализовывать конкретную идею, заложенную в каждом отдельном кодеке.

Ещё один хороший приём — использовать слабо связанные компоненты, чётко определив их функции.

Ruby

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

class AV1
  def encode(bytes)
  end
  def decode(bytes)
  end
end

class H264
  def encode(bytes)
  end
  def decode(bytes)
  end
end

# ...

supported_codecs = [AV1.new, H264.new, HEVC.new]

class MediaFramework
  def encode(type, bytes)
    codec = supported_codecs.find {|c| c.class.name.downcase == type}

    codec.encode(bytes)
  end
end

Этот код на Ruby отражает одну из описанных выше концепций. Без конкретизации в коде предполагается, что каждый кодек реализует функции encode и decode. Поскольку Ruby — язык с динамической типизацией, любой класс может иметь реализацию этих двух операций и работать как кодек.

Такой дизайн кода можно назвать хорошим, поскольку если нам потребуется добавить новый кодек, нужно только включить его реализацию в список. Разумеется, список можно сделать и динамическим. Смысл примера в том, что такой код легко расширять и поддерживать, поскольку компоненты слабо связаны между собой и каждый из них делает только то, что должен.

Фреймворк Ruby on Rails подталкивает к определённым способам организации кода, реализуя архитектуру «Модель-Представление-Контроллер» (MVC).

Go

Обращаясь к языкам со статической типизацией, таким как Go, нам придётся быть более формальными, описывая требуемые типы, но мы всё равно можем создать код, аналогичный приведённому выше.

type Codec interface {
   Encode(data []int) ([]int, error)
   Decode(data []int) ([]int, error)
} 

type H264 struct {
}

func (H264) Encode(data []int) ([]int, error) {
  // Много кода
  return data, nil
}

var supportedCodecs := []Codec{H264{}, AV1{}}

func Encode(codec string, data int[]) {
 // Здесь мы можем выбрать e, используя
 // supportedCodecs[0].Encode(data)
}

Тип interface в Go намного мощнее аналогичной конструкции в Java, так как его определение никак не связано с реализацией, и наоборот. Можно даже присвоить каждому кодеку тип ReadWriter и использовать в таком виде.

С

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

struct Codec
{
	*int (*encode)(*int);
	*int (*decode)(*int);
};


*int h264_encode(int *bytes)
{
// ...
}

*int h264_decode(int *bytes)
{
// ...
}

struct Codec av1 =
{
	.encode = av1_encode,
	.decode = av1_decode
};

struct Codec h264 =
{
	.encode = h264_encode,
	.decode = h264_decode
};

int main(int argc, char *argv[])
{
	h264.encode(argv[1]);
}

Примечание Код создан по примеру размещённого на сайте Computer Science from the Bottom Up.

Сначала в обобщённой структуре мы определяем абстрактные операции (в данном случае функции). Затем мы наполняем их конкретным кодом, например кодером и декодером кодека av1.

Множество других языков поддерживают сходные механизмы распределения методов или функций, как если бы они придерживались некой конвенции. В результате ПО на уровне ОС достаточно уметь работать только с показанными высокоуровневыми абстракциями.

Linux kernel и концепция «всё — файл»

Концепция «всё — файл» ОС Linux позволяет использовать один интерфейс для работы с любыми ресурсами системы. Например, Linux обрабатывает сетевые сокеты, особые файлы (такие как /proc/cpuinfo) и даже USB-устройства как файлы.

Этот подход облегчает разработку программ для ОС, поскольку мы можем использовать хорошо изученный набор операций для абстракции, названной «файлом». Вот как это работает:

# В первом, самом простом, случае мы читаем простой текстовый файл
$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...

# Здесь мы действуем, как если бы считывали простой файл,
# хотя на самом деле это не так (технически, конечно, именно так)
$ cat /proc/meminfo
MemTotal:        2046844 kB
MemFree:          546984 kB
MemAvailable:    1535688 kB
Buffers:          162676 kB
Cached:           892000 kB
 
# И наконец, мы открываем файл (используя fd=3) для чтения/записи
# Этот «файл» на самом деле — сокет. Затем мы отправляем запрос этому файлу >&3
# и считываем из него же
$ exec 3<> /dev/tcp/www.google.com/80
$ printf 'HEAD / HTTP/1.1\nHost: www.google.com\nConnection: close\n\n' >&3
$ cat <&3
HTTP/1.1 200 OK
Date: Wed, 21 Aug 2019 12:48:40 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
P3P: CP="This is not a P3P policy! See g.co/p3phelp for more info."
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: 1P_JAR=2019-08-21-12; expires=Fri, 20-Sep-2019 12:48:40 GMT; path=/; domain=.google.com
Set-Cookie: NID=188=K69nLKjqge87Ymv4h-gAW_lRfLCo7-KrTf01ULtY278lUUcaNxlEqXExDtVB104pdA8CLUZI8LMvJv26P_D8RMF3qCDzLTpjji96B9v_miGlZOIBro6pDreHP0yW7dz-9myBfOgdQjroAc0wWvOAkBu-zgFW_Of9VpK3IfIaBok; expires=Thu, 20-Feb-2020 12:48:40 GMT; path=/; domain=.google.com; HttpOnly
Accept-Ranges: none
Vary: Accept-Encoding
Connection: close

Это возможно только потому, что концепция файла (структуры данных и операции) была разработана как один из главных способов взаимодействия подсистем.  Вот участок API-структуры file_operations:

struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  // ...
}

Эта структура чётко определяет то, что мы подразумеваем под концепцией файла, и какое поведение мы от него ожидаем:

const struct file_operations ext4_dir_operations = {
	.llseek		= ext4_dir_llseek,
	.read		= generic_read_dir,
	// ...
};

Здесь можно увидеть набор функций, реализующих это поведение, в файловой системе ext4.

static const struct file_operations proc_cpuinfo_operations = {
	.open		= cpuinfo_open,
	.read		= seq_read,
	.llseek		= seq_lseek,
	.release	= seq_release,
};

Даже файлы cpuinfo proc реализованы через эту абстракцию. Фактически, работая с файлами под Linux, вы используете виртуальную файловую систему (VFS), которая в свою очередь обращается к функциям абстракции.

FFmpeg — форматы

Вот общая схема архитектуры процессов FFmpeg, демонстрирующая, что внутренние компоненты связаны в основном через такие абстрактные концепции, как AVCodec, а не напрямую через конкретные кодеки.

FFmpeg дизайн кода

Для входящих файлов в FFmpeg создаётся структура AVInputFormat, реализуемая любым форматом (видеоконтейнером), который требуется использовать. Файлы MKV также заполняют эту структуру своей реализацией, как и формат MP4 — своей.

typedef struct AVInputFormat {
    const char *name;
    const char *long_name;
    const char *extensions;
    const char *mime_type;
    ff_const59 struct AVInputFormat *next;
    int raw_codec_id;
    int priv_data_size;
    int (*read_probe)(const AVProbeData *);
    int (*read_header)(struct AVFormatContext *);
  }
  
  // matroska
  
  AVInputFormat ff_matroska_demuxer = {
    .name           = "matroska,webm",
    .long_name      = NULL_IF_CONFIG_SMALL("Matroska / WebM"),
    .extensions     = "mkv,mk3d,mka,mks",
    .priv_data_size = sizeof(MatroskaDemuxContext),
    .read_probe     = matroska_probe,
    .read_header    = matroska_read_header,
    .read_packet    = matroska_read_packet,
    .read_close     = matroska_read_close,
    .read_seek      = matroska_read_seek,
    .mime_type      = "audio/webm,audio/x-matroska,video/webm,video/x-matroska"
};

// mov (mp4)

AVInputFormat ff_mov_demuxer = {
    .name           = "mov,mp4,m4a,3gp,3g2,mj2",
    .long_name      = NULL_IF_CONFIG_SMALL("QuickTime / MOV"),
    .priv_class     = &mov_class,
    .priv_data_size = sizeof(MOVContext),
    .extensions     = "mov,mp4,m4a,3gp,3g2,mj2",
    .read_probe     = mov_probe,
    .read_header    = mov_read_header,
    .read_packet    = mov_read_packet,
    .read_close     = mov_read_close,
    .read_seek      = mov_read_seek,
    .flags          = AVFMT_NO_BYTE_SEEK | AVFMT_SEEK_TO_PTS,
};

Такой дизайн позволяет легко интегрировать новые кодеки, форматы и протоколы. В мае 2019 года в FFmpeg был включён кодек DAV1d (аналог av1 с открытым исходным кодом), и, изучив изменения в коде, вы увидите, насколько безболезненно прошло внедрение. В итоге ему только требуется зарегистрироваться в качестве доступного кодека и придерживаться списка общих операций.

+AVCodec ff_libdav1d_decoder = {
+    .name           = "libdav1d",
+    .long_name      = NULL_IF_CONFIG_SMALL("dav1d AV1 decoder by VideoLAN"),
+    .type           = AVMEDIA_TYPE_VIDEO,
+    .id             = AV_CODEC_ID_AV1,
+    .priv_data_size = sizeof(Libdav1dContext),
+    .init           = libdav1d_init,
+    .close          = libdav1d_close,
+    .flush          = libdav1d_flush,
+    .receive_frame  = libdav1d_receive_frame,
+    .capabilities   = AV_CODEC_CAP_DELAY | AV_CODEC_CAP_AUTO_THREADS,
+    .caps_internal  = FF_CODEC_CAP_INIT_THREADSAFE | FF_CODEC_CAP_INIT_CLEANUP |
+                      FF_CODEC_CAP_SETS_PKT_DTS,
+    .priv_class     = &libdav1d_class,
+    .wrapper_name   = "libdav1d",
+};`

Безотносительно используемого нами языка мы всегда можем как минимум попытаться создать код со слабой зависимостью и высокой согласованностью. Именно эти два основных свойства позволят вам писать ПО, которое легко расширять и поддерживать.

Не смешно? А здесь смешно: @ithumor