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

Отличия от традиционных операционных систем

В традиционных операционных системах (Windows, Linux, MacOS, Andrloid и т. д.):

  1. Приложение представляет собой «чёрный ящик»: именно разработчик приложения решает, что именно делать, какие устройства использовать, на какие события реагировать, с какими программами взаимодействовать, какой API предоставлять, и так далее.

  2. Общие компоненты — библиотеки — предоставляют API, специфичный для конкретной библиотеки (наборы функций для рисования одних и тех же фигур у OpenGL, GDI+ и Skia будет отличаться) и платформы (так, с вызовом сборки.Net из машинного кода, а скрипта Python — из программы на Java придётся повозиться).

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

"Сивелькирия" предлагает принципиально иной подход к процессу разработки ПО:

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

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

  3. Одинаковость и стабильность API гарантирует способность любых программ к взаимодействию в терминах, в которых работает конечный пользователь («текстовое сообщение», «счёт на оплату», «видеоролик» и т. д.). Выстраивание систем (в т. ч. распределённых) из модулей и управление связями между ними становятся задачами администрирования.

Основной плюс такого подхода состоит в том, что любая программа может взаимодействовать с любой другой программой, причём сам пользователь решает, как именно. Собрать в одном месте все сообщения, полученные от одного человека по разным каналам? Пожалуйста. Перебросить окно произвольного приложения с десктопа на планшет? Конечно, без необходимости поддерживать это в коде самой программы. Подключить приложение для автоматической торговли к онлайн‑игре вместо биржи? Не проблема.

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

Не только ОС, но и среда выполнения

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

Поэтому в качестве первого шага «Сивелькирия» предстаёт в виде среды выполнения модулей под внешней операционной системой. Это даёт следующие возможности:

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

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

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

  4. Готовые модули можно использовать из внешних программ как обычные библиотеки. Бонусом идёт возможность замены используемого модуля без переписывания кода (и даже перекомпиляции), поскольку API одинаковый.

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

Архитектура решения

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

Модули под управлением ядра
Модули под управлением ядра

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

Объекты-серверы (заполненные прямоугольники) и прокси-объекты (пустые прямоугольники) в модулях и ядре
Объекты-серверы (заполненные прямоугольники) и прокси-объекты (пустые прямоугольники) в модулях и ядре

Так, на рисунке выше объект 3 является прокси для доступа к серверу 1. Сервер 2 доступен через прокси 4 и 19, сервер 9 — через прокси 10, сервер 16 — через прокси 7, а сервер 8 — через прокси 11 и 15. На сервер 12 не ссылается ни одного прокси, а единственный прокси 14, ссылающийся на сервер 13, находится в том же модуле, что и сервер. На сервер 5 ссылаются сразу 3 прокси: 6, 17 и 18.

Ядро присваивает 64-битный дескриптор каждому созданному объекту. Каждый модуль имеет собственное пространство дескрипторов и может осуществлять доступ лишь к тем объектам, которые были им созданы или ссылки на которые были им получены. При передаче дескриптора между модулями модуль‑получатель получает дескриптор вновь созданного прокси‑объекта. У модуля нет возможности проверить, ссылаются ли два дескриптора на один объект‑сервер, или какому модулю или ядру принадлежит соответствующий объект‑сервер. Модули ведут подсчёт переданных ссылок на созданные ими серверы и получают уведомления от ядра о том, что эти ссылки были удалены.

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

Взаимодействие ядра с модулями, а также по сети с другими ядрами
Взаимодействие ядра с модулями, а также по сети с другими ядрами

Общий код поддержки домена работает с информацией об известных домену объектах и активных вызовах. Код поддержки платформы модуля отвечает за передачу вызовов и данных от ядра к модулю и обратно: он будет отличаться для модулей, содержащих машинный код, управляемый код и тексты скриптов. SDK специфичен для конкретного языка и решает две задачи: предоставляет программисту информацию о типах, доступных в API, и максимально упрощает обработку входящих и осуществление исходящих вызовов, чтобы программисту не приходилось самостоятельно вызывать функции ядра. В этом смысле использование SDK не является обязательным: взаимодействовать с ядром можно и напрямую. Код связи с удалённым ядром пакует данные, предназначенные для передачи по сети, и через ядро передаёт их модулю поддержки сети, во всех смыслах являющемуся обычным модулем. На другом конце происходит обратный процесс распаковки данных и передачи их в ядро.

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

Поддержанные в данный момент модули, упакованные в динамические библиотеки, экспортируют 6 функций, используемых ядром для:

  1. Инициализации модуля;

  2. Вызова метода сервера, созданного модулем;

  3. Уведомления модуля о высвобождении внешних ссылок на объект‑сервер;

  4. Очистки данных, возвращённых вызовами методов серверов и полностью прочитанных ядром;

  5. Уведомления модуля о завершении исходящего вызова (при наличии подписки);

  6. Уведомления модуля о наступлении события (при наличии подписки).

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

Типы вызовов

Вызовы между модулями — то, ради чего строится вся система. Система способна обрабатывать и передавать следующие типы вызовов:

  1. Вызовы методов: объект‑сервер реализует методы, доступные модулям, получившим ссылку (прокси) на данный сервер. Передача данных осуществляется в обе стороны: сначала аргументы метода передаются от вызывающего модуля к вызываемому, а затем результат его работы (изменённые ссылочные и выходные аргументы, возвращаемое значение, информация об ошибках) передаётся в обратную сторону. Вызывающий модуль может проверять состояние вызова, ожидать его завершения, а также подписываться на уведомление о завершении вызова.

  2. Уведомления о событиях: объект‑сервер предоставляет слоты событий, а модули, получившие ссылку (прокси) на данный объект, могут подписываться на эти события. При этом данные передаются только от модуля, инициировавшего события, к модулю, подписавшемуся на него. Одно событие может быть обработано несколькими подписчиками, но инициатор события не получает от них никакой информации.

  3. Потоки, обеспечивающие однонаправленную передачу сообщений фиксированной структуры, с гарантией доставки и без такой гарантии (аналоги pipов).

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

Порядок обработки несколько различается для разных типов вызовов, но основные шаги выглядят так:

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

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

  3. Ядро проверяет, соответствуют ли входные параметры контракту вызова.

  4. Ядро проверяет, обрабатывается ли данный вызов на стороне ядра или на стороне модуля. Вызовы, обрабатывающиеся на стороне ядра, выполняются на данных, уже полученных на шаге 2.

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

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

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

Для передачи информации об ошибках используется подход, сочетающий коды возврата и исключения. Во всех случаях передаётся пара 64-битных дескрипторов: тип ошибки и ссылка на объект, описывающий её. SDK может преобразовывать эти дескрипторы в исключения целевого языка, но модули, написанные не в ООП‑стиле, могут работать с ними как с кодами возврата.

При возникновении ошибок различного рода ядро следит за тем, чтобы не допустить утечки дескрипторов: если передать ссылку на объект по какой‑либо причине невозможно, счётчик ссылок изменяется соответствующим образом. Данные передаются как последовательности бинарных полей, выровненных по 8 байт (кроме массивов примитивных типов, элементы которых расположены без отступов).

Листинг ниже показывает примеры того, как могут выглядеть вызовы через фреймворк.
// Асинхронные вызовы в стиле C++ с передачей объектов и последующим ожиданием.
::sivdk::outgoing_call<decltype(&receiver_0::receive), &receiver_0::receive>
    message_1(filter2, make_shared<message>(u"Test", u"This is a test message."));
::sivdk::outgoing_call<decltype(&receiver_0::receive), &receiver_0::receive>
    message_2(filter2, make_shared<message>(u"Spammer", u"Some spam."));
::sivdk::outgoing_call<decltype(&receiver_0::receive), &receiver_0::receive>
    message_3(filter2, make_shared<message>(u"Your friend", u"I hate you, Mr. Bossman!"));

message_1.start();
message_2.start();
message_3.start();

message_1.wait();
message_2.wait();
message_3.wait();

// Синхронные вызовы трёх методов в стиле C++.
m_output->write(message->get_sender_name() + u": " + message->get_text() + u"\n");

// Вызов того же метода writer::write(), но в стиле C.
struct
{
    handle_type m_exception_instance;
    handle_type m_exception_type;
    uint64_t m_length;
    const char16_t* m_data;
} outgoing_invocation_data;
outgoing_invocation_data.m_length = d->m_length;
outgoing_invocation_data.m_data = d->m_data;

size_t release_count = 0;
const handle_type outgoing_invocation_handle =
    sivdk_invoke(c->m_output, interface_writer, 0, &outgoing_invocation_data, NULL, NULL, NULL, &release_count);
const invocation_status result = sivdk_invocation_wait(outgoing_invocation_handle);
if (result == invocation_status_exception)
    sivdk_release_object_handle(outgoing_invocation_data.m_exception_instance);

Типы данных

Все описанные выше вызовы позволяют передавать данные следующих типов:

  1. Ссылки на объекты и исключения (в этом смысле потоки отличаются от традиционных pipов тем, что могут доставлять не только сырые последовательности байтов, но и регистрировать прокси на принимающей стороне, не допуская утечки дескрипторов);

  2. Примитивные арифметические типы;

  3. Три типа перечислимых типов: обычные enumы, enumы с отношением «родитель‑предок» между значениями и наборы битовых флагов;

  4. Строки в UTF-16;

  5. Структуры (наборы полей произвольных типов);

  6. Контейнеры произвольных типов.

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

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

Схема типов представляет собой набор файлов XML, из которых затем генерируется код SDK для всех поддерживаемых языков и платформ, а также код стороны ядра для обработки этих типов. Для одного и того же языка может существовать более одного SDK: например, для C++, помимо «обычного» SDK, распаковывающего полученные от ядра данные в типы STL, можно создать SDK, работающий в типах Qt, а также «быстрый» SDK, избегающий создания объектов на куче для каждого полученного дескриптора объекта, но усложняющий процесс создания объектов‑серверов и управления памятью для них.

Пример интерфейса и его реализации в объектном и функциональном стиле

Интерфейс, содержащий метод, позволяющий записывать строки в поток, выглядит так:

<?xml version="1.0" encoding="utf-8"?>
<interface xmlns="https://api.sivelkiria.org/sivelkiria_type_definition.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="../sivelkiria_type_definition.xsd" support-type="in-developent">
    <name>text.writer</name>
    <version-major>0</version-major>
    <version-minor-max>0</version-minor-max>
    <description language="en-US">
        Text writer.
    </description>
    <method name="write" id="0" since-version-minor="0">
        <argument name="text" direction="in">
            <type name="primitive.string" version-major="0"/>
            <description language="en-US">
                Text to write.
            </description>
        </argument>
        <description language="en-US">
            Writes text to the writer.
        </description>
        <exception-info language="en-US">
            Doesn't throw.
        </exception-info>
    </method>
</interface>

Чтобы реализовать его на C++, достаточно унаследовать соответствующий класс:

static const u16string bad_word = u"Mr. Bossman";
static const u16string good_word = u"Mr. *******";

class processor : public writer_0
{
public:
    processor(const shared_ptr<writer_0>& output)
        : interface(interface_handle::text__writer_0, get_interface_minor_version())
        , m_output(output)
    {}
    virtual ~processor() = default;

    void write(const u16string& text) override
    {
        u16string::size_type last = 0;
        u16string result; // Хочу ou16stringstream

        while (true)
        {
            const auto bad_word_at = text.find(bad_word, last);
            if (bad_word_at == u16string::npos)
                break;

            result += text.substr(last, bad_word_at - last);
            result += good_word;
            last = bad_word_at + bad_word.size();
        }

        result += text.substr(last);
        m_output->write(result);
    }

private:
    const shared_ptr<writer_0> m_output;
};

Реализация на чистом C использует функции ядра напрямую, а также экспортирует точку входа, вызываемую ядром:

BOOL MODULE_API_EXPORTS handle_call(handle_type object_handle, void* object_pointer, handle_type interface,
                                    uint32_t method, void* data, handle_type invocation, void** invocation_pointer)
{
    struct
    {
        handle_type m_exception_instance;
        handle_type m_exception_type;
        handle_type m_next_field;
    }* d = data;
    d->m_exception_instance = invalid_handle;
    d->m_exception_type = invalid_handle;

    struct host* h = object_pointer;
    struct interface_definition* i = h->m_interface;
    while (i && i->m_handle != interface)
        i = i->m_parent;
    assert(i);

    // Следующий вызов уходит в invoke_text_writer().
    (*i->m_invoke)(h->m_data, method, &d->m_next_field);
    return 1;
}

void invoke_text_writer(void* object_data, uint32_t method, void* data)
{
    switch (method)
    {
    case 0:
    {
        // Метод write
        struct
        {
            uint64_t m_length;
            const char16_t* m_data;
        }* d = data;
        struct censorer* c = object_data;

        int found = 0;
        for (size_t i = 0; i + 4 <= d->m_length; ++i)
            if ((d->m_data[i] == u'T' || d->m_data[i] == u't') && d->m_data[i + 1] == u'e' && d->m_data[i + 2] == 's' &&
                d->m_data[i + 3] == 't')
            {
                char16_t* const changed = malloc(sizeof(char16_t) * d->m_length * 3);
                memcpy(changed, d->m_data, sizeof(char16_t) * i);
                changed[i] = d->m_data[i] == u'T' ? 'P' : 'p';
                memcpy(changed + i + 1, u"roduction", sizeof(char16_t) * 9);

                size_t last_in = i + 4;
                size_t last_out = i + 10;

                for (i = last_in; i + 4 <= d->m_length; ++i)
                {
                    if ((d->m_data[i] == u'T' || d->m_data[i] == u't') && d->m_data[i + 1] == u'e' &&
                        d->m_data[i + 2] == 's' && d->m_data[i + 3] == 't')
                    {
                        memcpy(changed + last_out, d->m_data + last_in, sizeof(char16_t) * (i - last_in));
                        last_out += (i - last_in);
                        changed[last_out] = d->m_data[i] == u'T' ? 'P' : 'p';
                        memcpy(changed + last_out + 1, u"roduction", sizeof(char16_t) * 9);
                        last_out += 10;
                        last_in = i + 4;
                        i += 3;
                    }
                }
                memcpy(changed + last_out, d->m_data + last_in, sizeof(char16_t) * (d->m_length - last_in));
                size_t end = last_out + (d->m_length - last_in);

                struct
                {
                    handle_type m_exception_instance;
                    handle_type m_exception_type;
                    uint64_t m_length;
                    const char16_t* m_data;
                } outgoing_invocation_data;
                outgoing_invocation_data.m_length = end;
                outgoing_invocation_data.m_data = changed;

                size_t release_count = 0;
                const handle_type outgoing_invocation_handle = sivdk_invoke(
                    c->m_output, interface_writer, 0, &outgoing_invocation_data, NULL, NULL, NULL, &release_count);
                const invocation_status result = sivdk_invocation_wait(outgoing_invocation_handle);
                if (result == invocation_status_exception)
                    sivdk_release_object_handle(outgoing_invocation_data.m_exception_instance);
                else
                    assert(result == invocation_status_success);

                free(changed);
                return;
            }

        struct
        {
            handle_type m_exception_instance;
            handle_type m_exception_type;
            uint64_t m_length;
            const char16_t* m_data;
        } outgoing_invocation_data;
        outgoing_invocation_data.m_length = d->m_length;
        outgoing_invocation_data.m_data = d->m_data;

        size_t release_count = 0;
        const handle_type outgoing_invocation_handle =
            sivdk_invoke(c->m_output, interface_writer, 0, &outgoing_invocation_data, NULL, NULL, NULL, &release_count);
        const invocation_status result = sivdk_invocation_wait(outgoing_invocation_handle);
        if (result == invocation_status_exception)
            sivdk_release_object_handle(outgoing_invocation_data.m_exception_instance);
        break;
    }
    default:
        assert(0);
    }
}

Как видим, код на C++ короче, зато C не копирует строки лишний раз.

Версионирование

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

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

  1. Для методов, добавленных в интерфейс уже после версии, реализованной объектом‑сервером, — выполнение версии по умолчанию на стороне ядра, а при её отсутствии — возврат исключения «не реализовано».

  2. Для структур — удаление лишних или добавление недостающих полей (разумеется, без утечки дескрипторов объектов).

  3. Для перечислимых типов — удаление лишних флагов, а также замену неизвестных получателю значений значениями по умолчанию.

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

Для удобства программиста SDK предлагает несколько способов реализации интерфесов объектами‑серверами. Так, программист может унаследовать максимально доступную версию интерфейса — и получить ошибку вида «нереализованный виртуальный метод» после обновления SDK, если к интерфейсу были добавлены новые методы. Другой вариант — реализовать конкретную версию интерфейса и переложить на ядро обработку вызовов методов, добавленных в более поздних версиях.

Статус проекта

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

  1. Разработано XML‑представление типов, которое позволяет расширять их путём добавления новых версий.

  2. Написан (на PHP) и покрыт тестами кодогенератор, на базе данного XML‑представления создающий код данных типов для C++ SDK и ядра. Генератор покрыт тестами (в т. ч. на компилируемость и работу генерированного кода) и может быть относительно легко расширен для поддержки новых языков и платформ SDK.

  3. Написано (на C++) и покрыто тестами ядро, которое умеет загружать в текущий процесс модули из динамических библиотек под Windows и Linux, отслеживать создание и удаление объектов и передавать вызовы методов между модулями.

  4. Написан и покрыт тестами SDK, позволяющий писать модули на языке C++: реализовывать интерфейсы и вызывать методы объектов через ядро.

  5. На стороне ядра и C++ SDK поддержана работа с примитивными типами, объектами, исключениями, строками, контейнерами и перечислениями. На упаковку и распаковку всех этих типов есть тесты.

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

В настоящее время я работаю над поддержкой событий, а также над некоторыми мелкими исправлениями и улучшениями. Закончив с событиями, планирую поддержать загрузку модулей в отдельные процессы, а затем — взаимодействие ядер по сети. Интересными задачами выглядят добавление поддержки других языков и платформ (например, .Net, Python или Java), поддержка кросс‑модульной «сборки мусора» и персистентности.

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

  1. Выгрузка модулей.

  2. Потоки, хранилища реального времени.

  3. Система безопасности, контролирующая доступ к методам интерфейсов.

  4. Работа с модулями в форматах, отличных от динамических библиотек для текущей операционной системы.

Если же рассматривать более общую картину, то ситуация выглядит так. В данный момент разрабатывается прототип системы, призванный выявить и решить технические проблемы, связанные с разработкой, а также послужить демонстрацией возможностей. Прототип предоставляет существенно меньшую функциональность, чем конечный продукт: например, в нём отсутствует возможность обновления схемы типов без перезапуска (или даже перекомпиляции) системы, не говоря уже о реализации системных функций (графика, сеть, GUI и т. п.) — подобные функции пока берутся у внешней операционной системы.

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

Зачем всё это?

Я уже писал в своё время о миссии данной операционной системы. Ниже приведу лишь короткий список преимуществ, которые такой подход призван дать:

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

  2. Возможность использовать часть программы, взяв от целого лишь нужное. Установка программы для работы с редким файловым форматом больше не означает отказа от привычного офисного пакета, поскольку копонент поддержки формата файла можно использовать с привычным интерфейсом. Для программиста это означает открытость API любой программы, установленной в системе.

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

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

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

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

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

  8. Совместимость модулей гарантируется системой. То же относится к возможности их взаимодействия - в том числе по сети. Программисту не требуется тратить ресурсы на поддержку распределённых версий программ, поскольку система уже скрывает подробности о расположении модулей, с которыми осуществляется связь.

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

Разумеется, имеются и недостатки. Чтобы сэкономить время комментаторов, перечислю те, что уже обсуждались ранее:

  1. Перенос существующего кода на новую платформу потребует существенной работы по декомпозиции.

  2. Бизнес‑стратегии распространения кода и содержимого, основанные на попытках запереть пользователя в рамках определённой инфраструктуры, противоречат предложенному подходу.

  3. Разбиение программ на модули несёт дополнительные накладные расходы.

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

  5. Несмотря на популярность Web‑сервисов, данная система предназначена, скорее, для локального использования. Справедливости ради стоит отметить, что построение на её базе Web‑сервисов (в т. ч. распределённых) также может дать определённые преимущества.

Заключение

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

Исходный код и продуктовый Jenkins открыты, при желании с ними можно ознакомиться. Если у кого‑то появятся вопросы, постараюсь на них ответить. Если кого‑то заинтересует возможность присоединиться к проекту — пишите в личку, обсудим.

Комментарии (3)


  1. gatoazul
    16.05.2023 11:39

    В Plan9 взаимодействие с сервером сделано куда проще - через простой протокол 9p. И работать проще, и монструозных SDK не требуется (которые в случае динамических языков крайне неудобны).


  1. PuerteMuerte
    16.05.2023 11:39
    +2

    Операционная система-суперприложение а-ля WeChat, без какой-либо возможности переносить существующие приложения без полного переписывания с самого фундамента? Попробовать можно, наверняка какую-то аудиторию любителей найдёт, но сомневаюсь в популярности этого подхода. Пользователю ведь пофигу на внутреннюю архитектуру, ему надо нажать иконку и открыть свои сообщения в мессенджере или видосик, популярность будет обеспечиваться только количеством нормально портированного софта на эту платформу.


  1. a-tk
    16.05.2023 11:39

    Что-то в процессе чтения сего опуса меня не покидало ощущение дежавю с нафталином.

    То COM, то CLR, то SOAP... Притом многое почему-то взято с кладбища технологий.

    Кстати, этот самый ввод-вывод хоть асинхронным предполагается-то?