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

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

Введение

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

Допустим, мы разрабатываем фреймворк, необходимый для обработки аудио- и видеофайлов. Следует учесть, что кодеки H264, AV1, 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

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

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

Кстати, к определённым способам организации кода подталкивает и фреймворк Ruby on Rails, но уже через архитектуру MVC.

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 каждому кодеку и использовать его в таком виде.

С

На «Си» мы также можем создать код со схожим поведением при некоторых отличиях:

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]);
}

Смотрите, изначально в обобщённой структуре определяются абстрактные операции (в нашем случае функции). Потом они наполняются конкретным кодом, к примеру, декодером и кодером кодека AV1.

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

В итоге софту на уровне операционной системы достаточно лишь уметь работать с показанными абстракциями высокого уровня.

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

Эта концепция операционной системы «Линукс» даёт нам возможность применять один интерфейс при работе с любыми системными ресурсами. Допустим, при обработке сетевых сокетов, особых файлов и даже USB-устройств как файлов.

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

Посмотрим, как это функционирует:

# В 1-м, самом простом случае, мы просто читаем несложный текстовый файл
$ 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-файлы реализованы посредством этой абстракции. То есть по факту при работе с файлами под «Линукс», вы применяете VFS, а она, в свою очередь, выполняет обращение к абстрактным функциям.

FFmpeg — форматы

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

Идём дальше. Для входящих файлов в 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-кодек и, после изучения изменений в коде, вы увидите, насколько успешно прошло это внедрение. В итоге ему нужно лишь зарегаться в качестве доступного кодека, а потом придерживаться списка общих операций.

+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",
+};`

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

Источник — «Good Code Design From Linux/Kernel».