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

В данной статье рассматривается реализация ООБД в контексте разработки системы, состоящей из микросервисов, на примере Perst и Db4o. Также будет рассмотрена отдельная реализация с документно-ориентированной базой данных MongoDB, работа с которой имеет много общего с ООБД.

Целью данной статьи является рассмотрение практического применения ООБД и решения проблем совместимости с помощью микросервисной архитектуры.

Введение

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

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

В данной работе представлен так называемый "распределённый монолит", поскольку все единицы модульности (сервисы) зависят друг от друга.

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

Пример модели
namespace oodb_project.models
{
    /// <summary>
    /// Модель, характеризующая источник данных для сервиса
    /// </summary>
    public class DataSourceModel : IdModel
    {
        public DataSourceModel() : base()
        {
        }

        public DataSourceModel(string? id, string? name, string? url) : base(id)
        {
            Name = name;
            Url = url;
        }

        /// <summary>
        /// Имя ресурса
        /// </summary>
        public string? Name { get; set; }

        /// <summary>
        /// Ссылка на ресурс
        /// </summary>
        public string? Url { get; set; }
    }
}

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

Кто определяет общий формат выходных данных

Общий формат выходных данных для каждого сервиса определяет главный сервис - oodb-main-server. Выходные данные - это результат выполнения запроса конкретным сервисом (oodb-mongo-server или oodb-perst-server).

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

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

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

Предполагается, что при настройке MongoDB, подключение Perst и db4o к каждому конкретному сервису согласно рекомендациям, перечисленным в данной статье читатель сможет самостоятельно воспроизвести результаты данной работы (запустить систему).

Описание предметной области

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

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

Отмечу, что в данный момент существует большое множество инструментов, которые помогают решить большинство проблем связанных с непрерывной доставкой, интеграцией, автоматическим тестированием и мониторингом удалённых хостов. Например, CI/CD, Zabbix, Docker, Kubernetes, Shef, Ansible, NixOS и многие другие.

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

Проектирование модели базы данных

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

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

Основными сущностями, не считая связей между ними, будут следующие:

  1. Хосты (hosts) - это конкретные сервера, на которых работают какие-либо приложения (сервисы).

  2. Сервисы (services) - это конкретное приложение, которое может быть запущено на любом хосту.

  3. Ресурсы (data_sources) - это ссылки на ресурсы, которые используются сервисом для осуществления своей работы (например, ссылка на GitHub-репозиторий).

  4. Админы (admins) - это администратор системы, которому доступен просмотр технических характеристик хоста (мониторинг хоста).

  5. Журнал мониторинга (monitor_apps) - это журнал, в котором фиксируется каждый конкретный администратор и закреплённый за ним хост, чтобы каждый админ мог мониторить только ему доступные хосты.

Каждая сущность определяется своим набором атрибутов, которые позволяют описать её как объект в контексте объектно-ориентированного подхода. Атрибуты каждой отдельной сущности и отношения между всеми сущностями представлены на рисунке 1.

Рисунок 1 - Модель базы данных

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

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

Проектирование архитектуры системы

Что такое микросервисная архитектура? Микросервисная архитектура - это стиль проектирования, который разбивает систему на отдельные сервисы с разными функциями.

Единицей модульности в данном стиле проектирования выступает сервис.

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

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

Схема спроектированной архитектуры представлена на рисунке 2.

Рисунок 2 - Архитектура системы

Из рисунка 2 можно сделать вывод, что в дополнении к трём сервисам в системе были добавлены desktop-клиентское приложение и два контейнера, один из которых для работы с базой данных, а другой - для работы с MongoDB через веб-клиент.

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

Вообще, микросервисная архитектура хороша тем, что каждая отдельная единица модульности может быть разработана с использованием разных технологий - от Node.js, Gin-Gonic, Nest.js до Spring Boot, Laravel и т.д. Я считаю это одним из достоинств данной архитектуры.

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

Описание проблемы, которая решается с помощью данной архитектуры

При разработке сервиса для работы с базой данных Perst хотелось бы использовать ASP Net Core и более новую версию C#, т.к. технологии развиваются и не хочется "отставать" от общего потока развития. Однако, всё не так просто. Если сервисы для работы с MongoDB и db4o были разработаны с помощью .NET 6 и ASP NET Core, то с этим сервисом пришлось сделать "откат" технологий до .NET Framework 4.8 и использовать несколько иные подходы к проектированию работы приложения, потому что Perst не работает с новыми версиями .NET (не поддерживает). В качестве доказательства существования этой проблемы предлагаю читателю самостоятельно попробовать подключить Perst к более новым версиям .NET.

Разработка сервиса для работы с MongoDB (oodb-mongo-server)

Настройка базы данных

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

В начале необходимо развернуть базу данных MongoDB в контейнере, для взаимодействия с ней. Это необходимо, чтобы локально работать с данной базой без дополнительных инсталляций.

Я использовал docker-compose для развёртывания контейнера mongo, а также mongo-express для возможности использовать веб-интерфейс, взаимодействующей с MongoDB.

Следующие инструкции в docker-compose настраивают автоматическое локальное развёртывание описанных выше контейнеров:

version: '3.1'

services:

  mongo:
    image: mongo
    restart: always
    ports:
      - 27017:27017
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: example

  mongo-express:
    image: mongo-express
    restart: always
    ports:
      - 8081:8081
    environment:
      ME_CONFIG_MONGODB_ADMINUSERNAME: root
      ME_CONFIG_MONGODB_ADMINPASSWORD: example
      ME_CONFIG_MONGODB_URL: mongodb://root:example@mongo:27017/
    depends_on:
      - mongo

Здесь используется версия 3.1, описание состоит из двух сервисов: mongo и mongo-express.

В image указан образ, из которого будет собран контейнер. Если не указывать версию образа он будет загружен из Docker Hub согласно последней версии (она же является версией по-умолчанию).

Каждый из сервисов автоматически перезапускается, что обозначается как restart: always

В environment определяются значения конфигурации для подключения к базе данных (mongo-express) и инициализация паролем и логином базы данных для взаимодействия с ней (mongo).

Порты указаны в ports, а чтобы mongo-express запустился после mongo и был зависим от данного контейнера был указан depends_on в mongo-express.

Для запуска контейнеров, описанных в docker-compose, необходимо их сначала собрать используя команду docker-compose build , а затем запустить командой docker-compose up .

Рисунок 3 - Сборка контейнеров с помощью docker-compose

При запуске всех контейнеров они должны отображаться в списке контейнеров. Для этого удобно использовать команду docker container ps -a

Рисунок 4 - Активные контейнеры

После того, как контейнеры были развёрнуты следует проверить подключение к MongoDB с помощью веб-интерфейса. В случае текущих настроек, которые были определены в docker-compose, путь к веб-интерфейсу определён по адресу http://localhost:8081

Рисунок 5 - Веб-интерфейс для работы с MongoDB

При работе с веб-интерфейсом необходимо создать базу данных oodb (я это уже сделал), и в данной базе данных создать следующие коллекции: AdminList, DataSourceList, HostList, HostServiceList, MonitorAppList, ServiceList.

И при такой настройке MongoDB исходный код, который будет представлен в конце статьи, будет работать со средой тех читателей, которые решат попробовать запустить исходный код.

Рисунок 6 - Завершающий этап настройки MongoDB

Приступим непосредственно к разработке сервиса взаимодействия с MongoDB.

Разработка функционала

Файловая структура сервиса

Для начала стоит ознакомиться с получившейся структурой сервиса. Она представлена на рисунке 7.

Рисунок 7 - Структура проекта oodb-mongo-server

Точкой входа в сервис является файл Program.cs

В constants расположены константы API-маршрутов, по которым с этим сервисом можно взаимодействовать по HTTP.

В controllers расположены основные контроллеры, которые реализуют CRUD-операции для каждой отдельной коллекции объектов.

В database находятся папки config и context, которые предназначены для конфигурирования подключения к MongoDB и получению контекста этой базы данных.

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

В utils располагаются утилиты. В данном сервисе это в основном утилита для работы с рефлексией.

Описание конфигурации MongoDB и её контекста

Файл определяющий конфигурацию MongoDB выглядит следующим образом:

namespace oodb_mongo_server.database.config
{
    /// <summary>
    /// Класс, реализующий интерфейс конфигурации MongoDB
    /// </summary>
    public class MongoDbConfig : IMongoDbConfig
    {
        /// <summary>
        /// Конструктор с параметром, принимающий в качестве значения интерфейс конфигурации
        /// </summary>
        /// <param name="config"></param>
        public MongoDbConfig(IConfiguration? config)
        {
            if (config != null)
            {
                Database = config["MongoDB:Database"];
                Port = int.Parse(config["MongoDB:Port"]);
                Host = config["MongoDB:Host"];
                User = config["MongoDB:User"];
                Password = config["MongoDB:Password"];
            }
        }

        /// <summary>
        /// Название базы данных
        /// </summary>
        public string? Database { get; set; }

        /// <summary>
        /// Хост
        /// </summary>
        public string? Host { get; set; }

        /// <summary>
        /// Порт, по которому происходит подключение к базе данных
        /// </summary>
        public int? Port { get; set; }

        /// <summary>
        /// Пользователь
        /// </summary>
        public string? User { get; set; }

        /// <summary>
        /// Пароль
        /// </summary>
        public string? Password { get; set; }

        /// <summary>
        /// Строка подключения (генерируется исхода из других атрибутов)
        /// </summary>
        public string? ConnectionString
        {
            get
            {
                if (string.IsNullOrEmpty(User) || string.IsNullOrEmpty(Password))
                {
                    return $@"mongodb://{Host}:{Port}";
                }

                return $@"mongodb://{User}:{Password}@{Host}:{Port}";
            }
        }
    }
}

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

Контекст, в рамках которого будут реализованы CRUD-операции, выглядит следующим образом:

namespace oodb_mongo_server.database.context
{
    /// <summary>
    /// Класс, реализующий интерфейс контекста MongoDB
    /// </summary>
    public class DbContext : IDbContext
    {
        /// <summary>
        /// База данных
        /// </summary>
        private readonly IMongoDatabase _db;

        /// <summary>
        /// Конструктор с параметром
        /// </summary>
        /// <param name="config">Конфигурация</param>
        public DbContext(IMongoDbConfig config)
        {
            // Создание нового клиента с передачей строки подключения
            var client = new MongoClient(config.ConnectionString);

            // Получение доступа к конкретной базе данных
            _db = client.GetDatabase(config.Database);
        }

        /// <summary>
        /// Коллекция объектов AdminList
        /// </summary>
        public IMongoCollection<AdminModel>? AdminList => _db.GetCollection<AdminModel>("AdminList");

        /// <summary>
        /// Коллекция объектов DataSourceList
        /// </summary>
        public IMongoCollection<DataSourceModel>? DataSourceList => _db.GetCollection<DataSourceModel>("DataSourceList");

        /// <summary>
        /// Коллекция объектов HostList
        /// </summary>
        public IMongoCollection<HostModel>? HostList => _db.GetCollection<HostModel>("HostList");

        /// <summary>
        /// Коллекция объектов HostServiceList
        /// </summary>
        public IMongoCollection<HostServiceModel>? HostServiceList => _db.GetCollection<HostServiceModel>("HostServiceList");

        /// <summary>
        /// Коллекция объектов MonitorAppList
        /// </summary>
        public IMongoCollection<MonitorAppModel>? MonitorAppList => _db.GetCollection<MonitorAppModel>("MonitorAppList");

        /// <summary>
        /// Коллекция объектов ServiceList
        /// </summary>
        public IMongoCollection<ServiceModel>? ServiceList => _db.GetCollection<ServiceModel>("ServiceList");
    }
}

В данном файле определены коллекции объектов (в виде публичных атрибутов класса DbContext), которые будут активно использоваться. Эти атрибуты позволяют напрямую обращаться к конкретным коллекциям в БД, полученным в результате подключения к MongoDB.

Описание точки входа в сервис

В точке входа в сервис определены все основные его настройки, подключение к MongoDB, инициализация контроллеров и запуск сервиса.

/*
 * Точка входа в сервис ooodb-mongo-server
 * **/

using MongoDB.Driver;
using oodb_mongo_server.database.config;
using oodb_mongo_server.database.context;
using oodb_project.controllers;

// Создание приложения
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Создание экземпляра MongoClient (подключение к БД)
MongoClient client = new MongoClient("mongodb://root:example@localhost:27017");

// Выпод коллекций из базы данных (проверка работы подключения)
using (var cursor = await client.ListDatabasesAsync())
{
    var databases = cursor.ToList();
    foreach (var database in databases)
    {
        Console.WriteLine(database);
    }
}

// Получение контекста базы данных на основе собранной конфигурации
var context = new DbContext(new MongoDbConfig(null)
{
    Database = "oodb",
    Host = "localhost",
    Password = "example",
    User = "root",
    Port = 27017
});

// Инициализация маршрутов API сервиса
var initMongoController = new InitMongoController(app, context);
initMongoController.InitRoutes();

// Запуск сервиса
app.Run();

Далее будет рассмотрено устройство контроллеров, которые позволяют реализовать бизнес-логику CRUD-операций.

Устройство контроллеров сервиса

Каждый контроллер является наследником абстрактного класса BaseController, который был создан с целью обобщить выполнение некоторых операций, в числе которых Create, Read, Read All, и Delete (Update не автоматизирована в силу сложности и оригинальности исполнения инструкций при данной операции).

Приведу пример обобщения функции для вывода всех объектов в коллекции:

        /// <summary>
        /// Метод для получения всех объектов определённого типа
        /// </summary>
        /// <returns>Результат работы функции (массив документов)</returns>
        public IResult GetAll()
        {
            // Проверка подключения к базе данных
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск всех элементов в коллекции определённого типа (входной тип)
                var list = _collection.Find(Builders<IT>.Filter.Empty).ToList();

                // Результат представлен в отдельной структуре, т.к. данные нужно преобразовать перед отправкой пользователю
                var result = new List<OT>();

                foreach (var item in list)
                {
                    // Получение всех полей элемента входного типа (IT)
                    var fields = ReflectionUtil.getFields(item);

                    // Процедура замены id входной модели (ObjectId) на id выходной модели (String)
                    var id = fields[0].ToString();
                    fields.RemoveAt(0);
                    if(id != null)
                    {
                        fields.Insert(0, id);
                    }

                    // Создание нового объекта выходного типа OT с передачей в его конструктор определённых полей
                    var value = Activator.CreateInstance(typeof(OT), fields.ToArray());

                    // Если value не равен null, то добавляем его в список результатов
                    if(value != null)
                    {
                        result.Add((OT)value);
                    }
                }

                // Возвращаем преобразованный массив результатов
                return Results.Json(result.ToArray());
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }
Полный код абстрактного класса
using oodb_mongo_server.models;
using MongoDB.Driver;
using oodb_mongo_server.models.data;
using oodb_project.models;
using oodb_mongo_server.utils;
using MongoDB.Bson;
using System.Reflection;


/*
 * В данном файле определён абстрактный класс BaseController, который
 * использует механизм обобщений.
 * Для данного класса необходимо указать тип для входных параметров и выходных параметров
 * Абстрактный класс определяет методы Get, GetAll и Create
 * **/

namespace oodb_mongo_server.controllers
{
    /// <summary>
    /// Абстрактный класс для контроллеров
    /// </summary>
    public abstract class BaseController<IT, OT> 
        where IT : IdModel                          // Тип для входных параметров
        where OT : IdDataModel, new()               // Тип для выходных параметров
    {
        // Коллекция входных параметров
        protected IMongoCollection<IT>? _collection;

        /// <summary>
        /// Конструктор абстрактного класса
        /// </summary>
        /// <param name="collection">Ссылка на коллекцию</param>
        public BaseController(IMongoCollection<IT>? collection)
        {
            _collection = collection;
        }

        /// <summary>
        /// Метод для создания нового объекта коллекции
        /// </summary>
        /// <param name="data">Данные объекта коллекции</param>
        /// <returns>Созданный объект коллекции</returns>
        protected IResult Create(OT data)
        {
            // Проверка подключения к базе данных
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Получение всех полей выходного объекта
                var fields = ReflectionUtil.getDataFields(data);

                // Получение информации о всех полях
                var fieldsInfo = ReflectionUtil.getDataFieldsInfo(data);

                if(fields.Count > fieldsInfo.Count)
                {
                    // Удаляем id в модели
                    fields.RemoveAt(0);
                }

                // Создаём объект входного типа
                var value = Activator.CreateInstance(typeof(IT), fields.ToArray());
                if (value != null)
                {

                    // Добавление объекта в коллекцию
                    _collection.InsertOne((IT)value);

                    var fieldsValue = ReflectionUtil.getFields((IT)value);

                    // Процедура замены id входной модели (ObjectId) на id выходной модели (String)
                    var id = fieldsValue[0].ToString();
                    fieldsValue.RemoveAt(0);
                    if (id != null)
                    {
                        fieldsValue.Insert(0, id);
                    }

                    var result = Activator.CreateInstance(typeof(OT), fieldsValue.ToArray());

                    // Возвращаем результат поиска, если при создании объекта не получилось значение null
                    return Results.Json(result);
                }

                // Возвращаем ошибку
                return Results.Json(new MessageModel("Ошибка: невозможно создать объект для коллекции"));
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Метод для получения всех объектов определённого типа
        /// </summary>
        /// <returns>Результат работы функции (массив документов)</returns>
        public IResult GetAll()
        {
            // Проверка подключения к базе данных
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск всех элементов в коллекции определённого типа (входной тип)
                var list = _collection.Find(Builders<IT>.Filter.Empty).ToList();

                // Результат представлен в отдельной структуре, т.к. данные нужно преобразовать перед отправкой пользователю
                var result = new List<OT>();

                foreach (var item in list)
                {
                    // Получение всех полей элемента входного типа (IT)
                    var fields = ReflectionUtil.getFields(item);

                    // Процедура замены id входной модели (ObjectId) на id выходной модели (String)
                    var id = fields[0].ToString();
                    fields.RemoveAt(0);
                    if(id != null)
                    {
                        fields.Insert(0, id);
                    }

                    // Создание нового объекта выходного типа OT с передачей в его конструктор определённых полей
                    var value = Activator.CreateInstance(typeof(OT), fields.ToArray());

                    // Если value не равен null, то добавляем его в список результатов
                    if(value != null)
                    {
                        result.Add((OT)value);
                    }
                }

                // Возвращаем преобразованный массив результатов
                return Results.Json(result.ToArray());
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Метод для получения конкретного объекта из коллекции
        /// </summary>
        /// <param name="id">Идентификатор искомого объекта</param>
        /// <returns>Результат работы метода (конкретный объект)</returns>
        public IResult Get(string id)
        {
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск конкретного документа по его идентификатору в коллекции
                var data = _collection.Find(document => document.Id == ObjectId.Parse(id)).FirstOrDefault();

                // Проверка обнаружения объекта в коллекции (при отсутствии возвращаем ошибку)
                if (data == null)
                {
                    return Results.Json(new MessageModel($"Экземпляр объекта {typeof(IT).Name} с Id = {id} не обнаружен в БД"));
                }

                // Преобразование объекта входного типа в объект выходного типа
                var fields = ReflectionUtil.getFields(data);
                fields.RemoveAt(0);
                if (id != null)
                {
                    fields.Insert(0, id);
                }

                // Создаём объект выходного типа
                var value = Activator.CreateInstance(typeof(OT), fields.ToArray());
                if (value != null)
                {
                    // Возвращаем результат поиска, если при создании объекта не получилось значение null
                    return Results.Json((OT)value);
                }

                return Results.Json("Internal Server Error");
            }
            catch (Exception)
            {
                return Results.Json(new MessageModel($"Экземпляра объекта {typeof(IT).Name} с Id = {id} не обнаружен в БД"));
            }
        }

        /// <summary>
        /// Метод для удаления объекта из коллекции
        /// </summary>
        /// <param name="id">Идентификатор объекта</param>
        /// <returns>Удалённый объект коллекции</returns>
        public IResult Delete(string id)
        {
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                var data = _collection.Find(document => document.Id == ObjectId.Parse(id)).FirstOrDefault();
                if (data == null)
                {
                    return Results.Json(new MessageModel($"Экземпляра объекта {typeof(IT).Name} с Id = {id} не обнаружен в БД"));
                }

                // Удаление объекта из коллекции
                _collection.DeleteOne(x => x.Id == data.Id);

                var fields = ReflectionUtil.getFields(data);
                fields.RemoveAt(0);
                if (id != null)
                {
                    fields.Insert(0, id);
                }

                var value = Activator.CreateInstance(typeof(OT), fields.ToArray());
                if (value != null)
                {
                    return Results.Json((OT)value);
                }

                return Results.Json("Internal Server Error");
            }
            catch (Exception)
            {
                return Results.Json(new MessageModel($"Экземпляра объекта {typeof(IT).Name} с Id = {id} не обнаружен в БД"));
            }
        }
    }
}

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

Можно заменить, что при получении всех значений атрибутов входного типа (IT) в методе осуществляется удаление Id. Дело в том, что для модели типа IT все модели имеют id с типом ObjectId (необходимый для MongoDB), и для конвертации его в выходные модели (тип OT) необходимо удалить значение первого поля, добавить в него id конвертированный в строку (ObjectId -> ToString()) и создать на основе значений этих атрибутов экземпляр объекта типа OT, через Activator.CreateInstance() (конструктор будет найден подходящий, т.к. id теперь не типа ObjectId, а типа string). Автоматизация процесса конвертации моделей из одного типа в другой является одной из целей применения механизма рефлексии в абстрактном классе.

Обобщающий класс использует утилиту для рефлексии. Далее я приведу пример функции для получения значения полей типа:

        /// <summary>
        /// Получение значений всех полей объекта определённого типа (базовый тип - IdModel)
        /// </summary>
        /// <typeparam name="T">Тип целевого объекта</typeparam>
        /// <param name="element">Целевой объект</param>
        /// <returns>Список значений полей целевого объекта</returns>
        public static List<object> getFields<T>(T element) where T : IdModel
        {
            List<object> fields = new List<object>();

            // Определение флагов, по которым будет осуществляться поиск полей целевого объекта
            BindingFlags bindingFlags = BindingFlags.Public |
                    BindingFlags.NonPublic |
                    BindingFlags.Instance |
                    BindingFlags.Static;

            // Если element.Id не равен null, то добавляем его значение список fields
            if (element.Id != null)
            {
                fields.Add(element.Id);
            }

            // Проходим по всем атрибутам экземпляра типа данных T и добавляем значение каждого атрибута в fields
            foreach (FieldInfo field in element.GetType().GetFields(bindingFlags))
            {
                var value = field.GetValue(element);
                
                if(value != null)
                {
                    fields.Add(value);
                }
            }

            return fields;
        }

Данный код просто реализует функцию получения всех значений из полей экземпляра объекта типа T, который по своей сути ограничивается абстрактным классом с помощью where: where T : IdModel

Это сделано намерено. Здесь следует уточнить, с какой целью это было сделано.

Все входные и выходные модели - разные, поскольку модели связанные с объектами в коллекциях MongoDB должны иметь обязательный параметр ObjectId, который при конвертации в JSON-формат сгенерирует отдельную JSON-структуру, которая основным сервисом не определена. В данном случае основной сервис (oodb-main-server) определяет какого должны быть формата выходные данные.

namespace oodb_mongo_server.models
{
    /// <summary>
    /// Абстрактный класс модели входных данных.
    /// Используется для обобщения типов входных данных используемых внутри сервиса
    /// </summary>
    public abstract class IdModel
    {
        public IdModel()
        {
            Id = ObjectId.GenerateNewId();
        }

        public IdModel(ObjectId? id)
        {
            Id = id;
        }

        [BsonId]
        public ObjectId? Id { get; set; }
    }
}

Модель IdModel определяет ObjectId, которое необходимо для каждого объекта в коллекции MongoDB.

Пример наследования от данной модели представлен ниже:

namespace oodb_project.models
{
    /// <summary>
    /// Модель, характеризующая администратора приложения для мониторинга
    /// </summary>
    public class AdminModel : IdModel
    {
        public AdminModel() : base() {}

        public AdminModel(ObjectId? id, string? email) : base(id)
        {
            Email = email;
        }

        public AdminModel(string? email) : base()
        {
            Email = email;
        }

        /// <summary>
        /// Почтовый адрес пользователя
        /// </summary>
        public string? Email { get; set; }
    }
}

Модель AdminModel наследуется от IdModel, что позволяет использовать экземпляры данной модели при работе с механизмом рефлексии или абстрактным классом. Все модели используемые для работы с MongoDB наследуются таким образом.

Далее я опишу операцию обновления в контроллере AdminController

        /// <summary>
        /// Обновление объекта в коллекции Admin
        /// </summary>
        /// <param name="data">Новые данные для объекта</param>
        /// <returns>Данные обновлённого объекта</returns>
        public IResult Update(AdminDataModel data)
        {
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск конкретного объекта
                var admin = _collection.Find(document => document.Id == ObjectId.Parse(data.Id)).FirstOrDefault();
                if (admin == null)
                {
                    return Results.Json(new MessageModel($"Экземпляра объекта AdminModel с Id = {data.Id} не обнаружен в БД"));
                }

                // Создание фильтра для поиска объекта в коллекции
                var filter = Builders<AdminModel>.Filter.Eq(s => s.Id, ObjectId.Parse(data.Id));

                // Создаём определение для обновления объекта в коллекции
                var update = Builders<AdminModel>.Update.Set(s => s.Email, data.Email);

                // Обновляем объект в коллекции по определённому фильтру
                _collection.UpdateOne(filter, update);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }
Полный код контроллера
using MongoDB.Bson;
using MongoDB.Driver;
using oodb_mongo_server.controllers;
using oodb_mongo_server.database.context;
using oodb_project.models;

/*
 * В данном файле представлен класс AdminController, 
 * который является наследником абстрактного класса BaseController.
 * Тип "входной" модели для данных, курсирующих внутри системы является AdminModel
 * Тип "выходной" модели для данных, возвращаемых пользователю является AdminDataModel
 * В данном классе переопределены методы Update и Delete, т.к. логика данных методов
 * может различаться от класса к классу и не подлежит обобщению в рамках текущей системы.
 * Также в данном классе переопределён метод Create, т.к. требуется явное указание параметров
 * метода при отправки его контроллеру.
 * Методы Get, GetAll не переопределены и используются из абстрактного класса.
 * **/

namespace oodb_project.controllers
{
    /// <summary>
    /// Класс контроллера для таблицы Admin
    /// </summary>
    public class AdminController : BaseController<AdminModel, AdminDataModel>
    {
        // Контекст базы данных
        private DbContext _db;

        /// <summary>
        /// Конструктор класса
        /// </summary>
        /// <param name="db">Контекст базы данных</param>
        public AdminController(DbContext db) : base(db.AdminList) // Вызываем конструктор абстрактного класса
        {
            _db = db;
        }

        /// <summary>
        /// Обновление объекта в коллекции Admin
        /// </summary>
        /// <param name="data">Новые данные для объекта</param>
        /// <returns>Данные обновлённого объекта</returns>
        public IResult Update(AdminDataModel data)
        {
            if (_collection == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск конкретного объекта
                var admin = _collection.Find(document => document.Id == ObjectId.Parse(data.Id)).FirstOrDefault();
                if (admin == null)
                {
                    return Results.Json(new MessageModel($"Экземпляра объекта AdminModel с Id = {data.Id} не обнаружен в БД"));
                }

                // Создание фильтра для поиска объекта в коллекции
                var filter = Builders<AdminModel>.Filter.Eq(s => s.Id, ObjectId.Parse(data.Id));

                // Создаём определение для обновления объекта в коллекции
                var update = Builders<AdminModel>.Update.Set(s => s.Email, data.Email);

                // Обновляем объект в коллекции по определённому фильтру
                _collection.UpdateOne(filter, update);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }

        /// <summary>
        /// Создание объекта для коллекции Admin
        /// </summary>
        /// <param name="data">Данные объекта</param>
        /// <returns>Созданный объект</returns>
        public new IResult Create(AdminDataModel data)
        {
            return base.Create(data);
        }

        /// <summary>
        /// Удаление объекта из коллекции
        /// </summary>
        /// <param name="id">Идентификатор объекта в коллекции</param>
        /// <returns>Удалённый объект</returns>
        public new IResult Delete(string id)
        {
            if ((_collection == null) || (_db.MonitorAppList == null))
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                var data = _collection.Find(document => document.Id == ObjectId.Parse(id)).FirstOrDefault();
                if (data == null)
                {
                    return Results.Json(new MessageModel($"Экземпляра объекта {typeof(AdminModel).Name} с Id = {id} не обнаружен в БД"));
                }

                // Удаление элемента в коллекции
                _collection.DeleteOne(x => x.Id == data.Id);

                // Каскадное удаление (удаляет все записи в коллекции MonitorApp, которые связаны с текущим объектом Admin)
                _db.MonitorAppList.DeleteMany(x => x.Admin!.Id == data.Id);

                // Возвращение удалённой модели
                return Results.Json(new AdminDataModel(data.Id.ToString()!, data.Email!));
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }
    }
}

В данном контроллере используется абстрактный класс BaseController, который берёт на себя ответственность за работу функций Create, Get и GetAll.

Представленная выше функция Update реализована внутри каждого класса (AdminController в том числе) индивидуально.

Каким образом происходит связка контроллеров с конкретными маршрутами? Пример данного кода представлен ниже, на примере всё того же контроллера AdminController:

            /* ----------- */
            /* CRUD операции для Host */
            /* ----------- */
            var hostController = new HostController(_db);
            _app.MapPost(ApiUrl.API_SAVE_HOST, hostController.Create);
            _app.MapPost(ApiUrl.API_UPDATE_HOST, hostController.Update);
            _app.MapPost(ApiUrl.API_DELETE_HOST, hostController.Delete);
            _app.MapGet(ApiUrl.API_GET_HOST, hostController.Get);
            _app.MapGet(ApiUrl.API_GET_ALL_HOST, hostController.GetAll);

Инициализация находится в файле InitMongoController.cs

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

А на этом рассмотрение сервиса работы с MongoDB заканчивается.

Разработка сервиса для работы с Perst (oodb-perst-server)

Проблема поддержки новых версий

К сожалению Perst не работает с новыми версиями C# и .NET (последняя версия Perst поддерживает .NET Framework 4.8 и меньше). По этой причине нет возможности использовать ASP NET Core.

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

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

Данный сервис был разработан с использованием .NET Framework 4.8 в виде консольного приложения. Для коммуникации с данным сервисом был использован WebSocketSharp, который позволяет создавать WebSocket-сервер, что и было сделано. Также WebSocketSharp позволяет создавать WebSocket-клиент, для взаимодействия с сервером. В общем, полнодуплексный канал связи получается.

Настройка подключения к ООБД

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

Сделать это можно с помощью официального сайта. Там же есть руководство пользователя по настройке и установки данной БД.

Код инициализации базы данных расположен в точке входа в приложение и представлен в рамках следующего метода:

        /// <summary>
        /// Статический метод инициализации объектно-ориентированной базы данных
        /// </summary>
        static void initPerstDb()
        {
            // Создание нового хранилища
            _db = StorageFactory.Instance.CreateStorage();

            // Открытие файла базы данных для записи
            _db.Open("perst.dbs", 100);


            // Получение корневого класса
            if(_db.Root == null)
            {
                _db.Root = new PerstRoot(_db);
            }

            // Связка корневого класса с атрибутом текущего класса
            _root = (PerstRoot)_db.Root;
        }

Чем является корневой элемент базы данных? Всё просто. Если привести аналогию, то это тот же объект, что выступает в роли контекста для базы данных MongoDB в сервисе oodb-mongo-server. Только в данном случае контекст будет для базы данных Perst.

namespace ConsoleApp1.root
{
    /// <summary>
    /// Класс, представляющий корневой элемент базы данных
    /// </summary>
    public class PerstRoot : Persistent
    {
        /// <summary>
        /// Индекс для доступа к коллекции объектов Admin
        /// </summary>
        public FieldIndex idxAdmin;

        /// <summary>
        /// Индекс для доступа к коллекции объектов Host
        /// </summary>
        public FieldIndex idxHost;

        /// <summary>
        /// Индекс для доступа к коллекции объектов MonitorApp
        /// </summary>
        public FieldIndex idxMonitorApp;

        /// <summary>
        /// Индекс для доступа к коллекции объектов DataSource
        /// </summary>
        public FieldIndex idxDataSource;

        /// <summary>
        /// Индекс для доступа к коллекции объектов HostService
        /// </summary>
        public FieldIndex idxHostService;

        /// <summary>
        /// Индекс для доступа к коллекции объектов Service
        /// </summary>
        public FieldIndex idxService;

        /// <summary>
        /// Конструктор с параметром
        /// </summary>
        /// <param name="db">Хранилище</param>
        public PerstRoot(Storage db) : base(db)
        {
            idxAdmin = db.CreateFieldIndex(typeof(AdminModel), "Id", true);
            idxHost = db.CreateFieldIndex(typeof(HostModel), "Id", true);
            idxMonitorApp = db.CreateFieldIndex(typeof(MonitorAppModel), "Id", true);
            idxDataSource = db.CreateFieldIndex(typeof(DataSourceModel), "Id", true);
            idxHostService = db.CreateFieldIndex(typeof(HostServiceModel), "Id", true);
            idxService = db.CreateFieldIndex(typeof(ServiceModel), "Id", true);
        }

        public PerstRoot()
        {

        }
    }
}

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

Разработка функционала

Файловая структура проекта

Рисунок 8 - Структура проекта сервиса oodb-perst-server

В constants определены константы для связывания путей и конкретных операций (аналогия с API).

В controllers располагаются контроллеры верхнего и низкого уровня.

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

В models определены модели, которые активно используются сервисом при работе как с ООБД, так и с выводом данных пользователю

В root расположен класс главного корневого элемента ООБД.

Описание точки входа в сервис

Точка входа в сервис выглядит следующим образом:

        /// <summary>
        /// Точка входа в консольное приложение
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
            // Инициализация БД Perst
            initPerstDb();

            // Наполнение БД тестовыми значениями
            var mockData = new MockData(_db, _root);
            mockData.generateData();

            // Прослушивание WebSocket-соединений
            var wssv = new WebSocketServer("ws://127.0.0.1");
            wssv.AddWebSocketService("/admin", initAdminHighController);
            wssv.AddWebSocketService("/host", initHostHighController);
            wssv.AddWebSocketService("/host-service", initHostServiceHighController);
            wssv.AddWebSocketService("/monitor-app", initMonitorAppHighController);
            wssv.AddWebSocketService("/data-source", initDataSourceHighController);
            wssv.AddWebSocketService("/service", initServiceHighController);

            // Начало прослушивания соединений
            wssv.Start();

            // Остановка работы консольного приложения
            Console.ReadKey(true);

            // Остановка прослушивания соединений
            wssv.Stop();

            // Закрытие соединения с базой данных
            _db.Close();
        }
Полный код точки входа
namespace ConsoleApp1
{
    internal class Program
    {
        /// <summary>
        /// Хранилище
        /// </summary>
        static private Storage _db;

        /// <summary>
        /// Корневой класс
        /// </summary>
        static private PerstRoot _root;

        /// <summary>
        /// Статический метод инициализации объектно-ориентированной базы данных
        /// </summary>
        static void initPerstDb()
        {
            // Создание нового хранилища
            _db = StorageFactory.Instance.CreateStorage();

            // Открытие файла базы данных для записи
            _db.Open("perst.dbs", 100);


            // Получение корневого класса
            if(_db.Root == null)
            {
                _db.Root = new PerstRoot(_db);
            }

            // Связка корневого класса с атрибутом текущего класса
            _root = (PerstRoot)_db.Root;
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов Admin
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера Admin</returns>
        static AdminHighController initAdminHighController()
        {
            return new AdminHighController(_db, _root);
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов Host
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера Host</returns>
        static HostHighController initHostHighController()
        {
            return new HostHighController(_db, _root);
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов HostService
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера HostService</returns>
        static HostServiceHighController initHostServiceHighController()
        {
            return new HostServiceHighController(_db, _root);
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов MonitorApp
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера MonitorApp</returns>
        static MonitorAppHighController initMonitorAppHighController()
        {
            return new MonitorAppHighController(_db, _root);
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов DataSource
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера DataSource</returns>
        static DataSourceHighController initDataSourceHighController()
        {
            return new DataSourceHighController(_db, _root);
        }

        /// <summary>
        /// Инициализация верхнеуровнего контроллера для коллекции объектов Service
        /// </summary>
        /// <returns>Экземпляр верхнеуровневого контроллера Service</returns>
        static ServiceHighController initServiceHighController()
        {
            return new ServiceHighController(_db, _root);
        }

        /// <summary>
        /// Точка входа в консольное приложение
        /// </summary>
        /// <param name="args"></param>
        static void Main(string[] args)
        {
            // Инициализация БД Perst
            initPerstDb();

            // Наполнение БД тестовыми значениями
            var mockData = new MockData(_db, _root);
            mockData.generateData();

            // Прослушивание WebSocket-соединений
            var wssv = new WebSocketServer("ws://127.0.0.1");
            wssv.AddWebSocketService("/admin", initAdminHighController);
            wssv.AddWebSocketService("/host", initHostHighController);
            wssv.AddWebSocketService("/host-service", initHostServiceHighController);
            wssv.AddWebSocketService("/monitor-app", initMonitorAppHighController);
            wssv.AddWebSocketService("/data-source", initDataSourceHighController);
            wssv.AddWebSocketService("/service", initServiceHighController);

            // Начало прослушивания соединений
            wssv.Start();

            // Остановка работы консольного приложения
            Console.ReadKey(true);

            // Остановка прослушивания соединений
            wssv.Stop();

            // Закрытие соединения с базой данных
            _db.Close();
        }
    }
}

В точке входа в текущий сервис происходит инициализация подключения к базе данных Perst, наполнение базы данных тестовыми значениями, добавление WebSocket-сервисов к экземпляру WebSocketServer (каждый из которых отвечает за определённую коллекцию объектов), запуск прослушивания соединений и этапы завершения работы сервиса.

Так как взаимодействие с данным сервисом реализовано с помощью WebSocket-соединения, то основной сервис и текущий обмениваются данными в JSON-формате, который заранее определён. Модель, которая используется при конвертации входных данных в текущем сервисе представлена ниже:

namespace ConsoleApp1.models
{
    /// <summary>
    /// Класс модели, которая используется для взаимодействия с другими сервисами
    /// </summary>
    public class HttpModel
    {
        public HttpModel()
        {
        }

        public HttpModel(string path, string payload)
        {
            Path = path;
            Payload = payload;
        }

        /// <summary>
        /// Путь, по которому отправить данные
        /// </summary>
        public string Path { get; set; }

        /// <summary>
        /// Отправляемые данные
        /// </summary>
        public string Payload { get; set; }
    }
}

Данная модель определяет полезные данные (Payload) и путь, по которому отправить эти данные (Path, используется для отправки данных конкретной CRUD-операции).

Устройство контроллеров

Контроллеры я разделил на две части:

  1. Верхнеуровневые контроллеры - контроллеры, которые по пути (Path) определяют, какой конкретно CRUD-операции низкого уровня отправлять данные (Payload).

  2. Низкоуровневые контроллеры - контроллеры, которые реализуют конкретные операции с ООБД

Абстрактный класс для верхнеуровневых контроллеров выглядит следующим образом:

namespace ConsoleApp1.controllers.high_level
{
    /// <summary>
    /// Абстрактный класс верхнеуровневых контроллеров
    /// </summary>
    public abstract class BaseHighController : WebSocketBehavior
    {
        // Ссылка на обработчик нижнего уровня
        protected IBaseLowController _controller;

        public BaseHighController(IBaseLowController controller)
        {
            _controller = controller;
        }

        /// <summary>
        /// Метод, обрабатывающий приход сообщений
        /// </summary>
        /// <param name="e"></param>
        protected override void OnMessage(MessageEventArgs e)
        {
            // Вызов функции InitSendRoute и передача ему десериализованных данных, полученных от вызывающей стороны
            InitSendRoute(JsonConvert.DeserializeObject<HttpModel>(e.Data));
        }

        /// <summary>
        /// Инициализация вызовов процедур, в зависимости от требуемого пути
        /// </summary>
        /// <param name="body">Данные, полученные от вызывающей стороны</param>
        public void InitSendRoute(HttpModel body)
        {
            if (body.Path == ApiPerstServiceUrl.GET_ALL)
            {
                Send(_controller.getAll());
                return;
            }
            else if (body.Path == ApiPerstServiceUrl.GET)
            {
                Send(_controller.get(body.Payload));
                return;
            }
            else if (body.Path == ApiPerstServiceUrl.CREATE)
            {
                Send(_controller.create(body.Payload));
                return;
            }
            else if (body.Path == ApiPerstServiceUrl.UPDATE)
            {
                Send(_controller.update(body.Payload));
                return;
            }
            else if (body.Path == ApiPerstServiceUrl.DELETE)
            {
                Send(_controller.delete(body.Payload));
                return;
            }

            Send(JsonConvert.SerializeObject(new MessageModel("Not found 404")));
        }
    }
}

Цель данного класса аналогична той, которая была у предыдущего сервиса.

В данном случае абстрактный класс реализует функцию InitSendRoute(), в которой идёт распределение Payload операциям расположенным по разным путям.

Переопределённый метод OnMessage() выступает в роли обработчика события "данным WebSocket-сервисом получено сообщение от WebSocket-клиента". Он просто вызывает InitSendRoute() с передачей ему десериализованных данных по общей модели.

С данным абстрактным классом определение классов-контроллеров высокого уровня очень небольшое. Например, для контроллера AdminHighController будет следующее определение:

namespace ConsoleApp1
{
    /// <summary>
    /// Обработчик верхнего уровня для коллекции объектов Admin
    /// </summary>
    public class AdminHighController : BaseHighController
    {
        public AdminHighController(Storage db, PerstRoot root) : base(new AdminLowController(db, root)){}
    }
}

А что же с низкоуровневыми контроллерами?

Здесь чуть сложнее. Для низкоуровневых контроллеров я использовал другой подход: я определил интерфейс и наследуясь от данного интерфейса реализовал методы контроллеров.

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

Определение интерфейса низкоуровневых контроллеров
namespace ConsoleApp1.controllers.low_level
{
    /// <summary>
    /// Интерфейс базового контроллера
    /// </summary>
    public interface IBaseLowController
    {
        /// <summary>
        /// Обновление объекта
        /// </summary>
        /// <param name="obj">Новые данные объекта</param>
        /// <returns>Данные обновлённого объекта</returns>
        string update(string obj);

        /// <summary>
        /// Создание объкета
        /// </summary>
        /// <param name="obj">Данные объекта</param>
        /// <returns>Данные созданного объекта</returns>
        string create(string obj);

        /// <summary>
        /// Получение списка объектов
        /// </summary>
        /// <returns>Список объектов</returns>
        string getAll();

        /// <summary>
        /// Получение конкретного объекта
        /// </summary>
        /// <param name="id">Идентификатор конкретного объекта</param>
        /// <returns>Данные объекта</returns>
        string get(string id);

        /// <summary>
        /// Удаление конкретного объекта
        /// </summary>
        /// <param name="obj">Данные объекта для удаления</param>
        /// <returns>Удалённый объект</returns>
        string delete(string obj);
    }
}

Приведу пример реализации метода удаления в низкоуровневом контроллере HostLowController:

        /// <summary>
        /// Удаление объекта HostModel
        /// </summary>
        public string delete(string id)
        {
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            HostOutputModel hostOutput;

            try
            {
                // Поиск модели Host по идентификатору
                HostModel host = (HostModel)_root.idxHost[id];
                // Проверка на нахождение модели по id
                if (host == null)
                {
                    return JsonConvert.SerializeObject(new MessageModel("Объекта по данному ID нет в БД"));
                }

                // Сборка выходной модели
                hostOutput = new HostOutputModel(
                    host.Id,
                    host.Name,
                    host.Url,
                    host.IPv4,
                    host.System
                );
                // Каскадное удаление
                host.CascadeDelete(_root);
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }

            return JsonConvert.SerializeObject(hostOutput);
        }
Полный код HostLowController.cs
namespace ConsoleApp1.controllers.low_level
{
    /// <summary>
    /// Класс низкоуровневого контроллера для коллекции объектов Host
    /// </summary>
    internal class HostLowController : IBaseLowController
    {
        /// <summary>
        /// Хранилище
        /// </summary>
        private static Storage _db;

        /// <summary>
        /// Корневой элемент ООБД
        /// </summary>
        private static PerstRoot _root;

        public HostLowController(Storage db, PerstRoot root)
        {
            _db = db;
            _root = root;
        }

        /// <summary>
        /// Обновление объекта HostModel
        /// </summary>
        public string update(string obj)
        {
            // Проверка на подключение к ООБД
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            // Десериализация входных данных
            var data = JsonConvert.DeserializeObject<HostOutputModel>(obj);
            try
            {
                // Поиск объекта host в коллекции объектов по ID
                HostModel host = (HostModel)_root.idxHost[data.Id];

                // Проверка обнаружения объекта
                if (host == null)
                {
                    return JsonConvert.SerializeObject(new MessageModel("Объекта по данному ID нет в БД"));
                }

                // Имзенение данных в найденном объекте host
                host.Url = data.Url;
                host.Name = data.Name;
                host.IPv4 = data.IPv4;
                host.System = data.System;

                // Фиксация изменений
                host.Modify();
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }

            return JsonConvert.SerializeObject(data);
        }

        /// <summary>
        /// Создание объекта HostModel
        /// </summary>
        public string create(string obj)
        {
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            var data = JsonConvert.DeserializeObject<HostOutputModel>(obj);
            try
            {
                // Добавление уникального идентификатора объекту
                data.Id = Guid.NewGuid().ToString();

                // Добавление объекта в коллекцию Host
                _root.idxHost.Put(new HostModel(data.Id, data.Name, data.Url, data.IPv4, data.System, _db.CreateLink(), _db.CreateLink()));
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }
            return JsonConvert.SerializeObject(data);
        }

        /// <summary>
        /// Получение всех объектов AdminModel
        /// </summary>
        public string getAll()
        {
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Получение всех данных коллекции
                HostOutputModel[] items = new HostOutputModel[_root.idxHost.Count];

                // Процесс конвертации полученных данных в выходные данные (аналогично сервису oodb-mongo-server)
                for (var i = 0; i < _root.idxHost.Count; i++)
                {
                    HostModel item = (HostModel)_root.idxHost.GetAt(i);
                    items[i] = new HostOutputModel(
                        item.Id,
                        item.Name,
                        item.Url,
                        item.IPv4,
                        item.System
                    );
                }

                // Сериализация результата
                return JsonConvert.SerializeObject(items);
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Получение конкретного объекта HostModel
        /// </summary>
        public string get(string id)
        {
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                HostOutputModel host = null;

                // Поиск объекта в коллекции объектов с конвертацией в выходную модель, при нахождении элемента
                for (var i = 0; i < _root.idxHost.Count; i++)
                {
                    HostModel item = (HostModel)_root.idxHost.GetAt(i);
                    if (item.Id == id)
                    {
                        host = new HostOutputModel(
                            item.Id,
                            item.Name,
                            item.Url,
                            item.IPv4,
                            item.System
                        );

                        break;
                    }
                }

                return JsonConvert.SerializeObject(host);
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Удаление объекта HostModel
        /// </summary>
        public string delete(string id)
        {
            if (_db == null)
            {
                return JsonConvert.SerializeObject(new MessageModel("Подключение к ООБД отсутствует"));
            }

            HostOutputModel hostOutput;

            try
            {
                HostModel host = (HostModel)_root.idxHost[id];

                if (host == null)
                {
                    return JsonConvert.SerializeObject(new MessageModel("Объекта по данному ID нет в БД"));
                }

                hostOutput = new HostOutputModel(
                    host.Id,
                    host.Name,
                    host.Url,
                    host.IPv4,
                    host.System
                );

                // Каскадное удаление объекта
                host.CascadeDelete(_root);
            }
            catch (Exception e)
            {
                return JsonConvert.SerializeObject(new MessageModel(e.Message));
            }

            return JsonConvert.SerializeObject(hostOutput);
        }
    }
}

В HostLowController, при удалении объекта реализуется механизм каскадного удаления. На примере данного метода я расскажу о том, каким образом он реализован.

При выполнении host.CascadeDelete(_root) осуществляется каскадное удаление конкретного объекта host из коллекции Host.

Если перейти к определению модели HostModel, то можно узнать каким образом реализуется механика каскадного удаления.

Механика каскадного удаления выглядит следующим образом:

        /// <summary>
        /// Каскадное удаление текущего экземпляра объекта из ООБД
        /// </summary>
        /// <param name="root">Корневой элемент ООБД</param>
        /// <returns></returns>
        public bool CascadeDelete(PerstRoot root)
        {
            // Проход по всем элементам коллекции HostServiceLink
            foreach (HostServiceModel item in HostServiceLink)
            {
                // Удаление связанных объектов
                item.Delete(root);
            }

            // Проход по всем элементам коллекции MonitorAppLink
            foreach (MonitorAppModel item in MonitorAppLink)
            {
                // Удаление связанных объектов
                item.Delete(root);
            }

            // Удаление текущего объекта по контексту
            return root.idxHost.Remove(this);
        }
Полное определение модели HostModel

namespace oodb_project.models
{
    /// <summary>
    /// Модель, характеризующая конкретный удалённый хост
    /// </summary>
    public class HostModel : Persistent
    {
        public HostModel()
        {
        }

        public HostModel(string id, string name, string url, string iPv4, string system, Link hostServiceLink, Link monitorAppLink)
        {
            Id = id;
            Name = name;
            Url = url;
            IPv4 = iPv4;
            System = system;
            HostServiceLink = hostServiceLink;
            MonitorAppLink = monitorAppLink;
        }

        /// <summary>
        /// Идентификатор объекта
        /// </summary>
        public string Id { get; set; }

        /// <summary>
        /// Имя
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// Адрес
        /// </summary>
        public string Url { get; set; }

        /// <summary>
        /// IP
        /// </summary>
        public string IPv4 { get; set; }

        /// <summary>
        /// Название системы
        /// </summary>
        public string System { get; set; }

        /// <summary>
        /// Ссылка на объект HostService (который ссылается на данный объект, в реляционном представлении)
        /// </summary>
        public Link HostServiceLink { get; set; }

        /// <summary>
        /// Ссылка на объект MonitorApp (который ссылается на данный объект, в реляционном представлении)
        /// </summary>
        public Link MonitorAppLink { get; set; }

        /// <summary>
        /// Каскадное удаление текущего экземпляра объекта из ООБД
        /// </summary>
        /// <param name="root">Корневой элемент ООБД</param>
        /// <returns></returns>
        public bool CascadeDelete(PerstRoot root)
        {
            // Проход по всем элементам коллекции HostServiceLink
            foreach (HostServiceModel item in HostServiceLink)
            {
                // Удаление связанных объектов
                item.Delete(root);
            }

            // Проход по всем элементам коллекции MonitorAppLink
            foreach (MonitorAppModel item in MonitorAppLink)
            {
                // Удаление связанных объектов
                item.Delete(root);
            }

            // Удаление текущего объекта по контексту
            return root.idxHost.Remove(this);
        }
    }
}

При каскадном удалении осуществляется проход по всем элементам связанных коллекций и удаление конкретных элементов в этих коллекциях. И только после удаления всех связанных объектов осуществляется удаление текущего.

Каждая модель, которая используется для работы с базой данных Perst наследуется от Persistent

Вот пример:

    /// <summary>
    /// Модель, характеризующая источник данных для сервиса
    /// </summary>
    public class DataSourceModel : Persistent

И это наследование очень мешает при преобразовании объекта в строку (сериализации). Поэтому было принято решение также как и в сервисе oodb-mongo-server производить деление моделей которые используются внутри системы (для работы с ООБД) и моделей, которые возвращаются основному сервису.

Таким образом реализован сервис oodb-perst-server.

Для более детального ознакомления с исходным кодом приложения следует перейти по ссылке на репозиторий сервиса.

Разработка сервиса для работы с db4o (oodb-main-server)

Файловая структура сервиса

Рисунок 9 - Файловая структура сервиса

В constants расположены константы, связывающий все маршруты API с контроллерами.

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

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

В models располагаются модели, активно используемые в данном сервисе.

Настройка подключения к базе данных

Подключить db4o можно достаточно просто - для этого можно воспользоваться пакетным менеджером NuGet и просто загрузить все необходимые компоненты для работы с данной библиотекой.

Ссылка на пакет в NuGet

Рисунок 10 - Установленная библиотека db4o-devel

Код в точке входа, который соответствует настройке и конфигурированию db4o выглядит следующим образом:

// При запуске проверяем есть ли файл БД, и если есть - удаляем его
if (File.Exists("db4o.yap"))
{
    File.Delete("db4o.yap");
}

// Конфигурирование ООБД (db4o)
IObjectContainer dbDb4o = Db4oEmbedded.OpenFile(Db4oEmbedded.NewConfiguration(), "db4o.yap");

// Заполнение ООБД тестовыми данными
var mockData = new MockData(dbDb4o);
mockData.generateData();

Разработка функционала

Описание точки входа в сервис

Код точки входа выглядит следующим образом:

/*
 * Точка входа в сервис oodb-main-server
 * **/

using Db4objects.Db4o;
using oodb_project.controllers.db4o;
using oodb_project.controllers.mongo;
using oodb_project.controllers.perst;
using oodb_project.data;

// Создание экземпляра класса WebApplicationBuilder для конфигурирования веб-приложения
var builder = WebApplication.CreateBuilder(args);

// Сборка веб-приложения
var app = builder.Build();

// Конфигурирование статических путей к файлам
app.UseDefaultFiles();
app.UseStaticFiles();

// При запуске проверяем есть ли файл БД, и если есть - удаляем его
if (File.Exists("db4o.yap"))
{
    File.Delete("db4o.yap");
}

// Конфигурирование ООБД (db4o)
IObjectContainer dbDb4o = Db4oEmbedded.OpenFile(Db4oEmbedded.NewConfiguration(), "db4o.yap");

// Заполнение ООБД тестовыми данными
var mockData = new MockData(dbDb4o);
mockData.generateData();

// Инициализация маршрутов для работы с ООБД db4o
var initDb4oController = new InitDb4oController(app, dbDb4o);
initDb4oController.InitRoutes();

// Инициализация маршрутов для работы с ООДБ perst
var initPerstController = new InitPerstController(app);
initPerstController.InitRoutes();

// Инициализация маршрутов для работы с MongoDB
var initMongoController = new InitMongoController(app);
initMongoController.InitRoutes();

// Запуск серверного приложения
app.Run();

// Закрытие соединений с базой данных
dbDb4o.Close();

В точке входа осуществляется сборка веб-приложения, настройка и конфигурирование ООБД db4o, инициализация маршрутов для работы с db4o, MongoDB и Perst и запуск серверного приложения. Все соответствующие этапы приведены к комментариях к коду.

Устройство контроллеров

В данном сервисе контроллеры устроены аналогично сервису oodb-mongo-server - также каждый контроллер наследуется от абстрактного базового класса, который покрывает определённое множество операций. Однако в данном случае обошлось без механизма рефлексии.

Например, так выглядит функция создания нового объекта:

        /// <summary>
        /// Метод для создания нового объекта коллекции
        /// </summary>
        /// <param name="data">Данные объекта коллекции</param>
        /// <returns>Созданный объект коллекции</returns>
        protected IResult Create(T data)
        {
            // Проверка подключения к базе данных
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Автоматическая генерация UUID
                data.Id = Guid.NewGuid().ToString();

                // Сохранение модели в ООДБ
                _db.Store(data);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }
Полный код абстрактного класса контроллеров db4o
namespace oodb_project.controllers.db4o
{
    /// <summary>
    /// Абстрактный класс, реализующий обобщённые методы CRUD-операций для коллекций объектов
    /// </summary>
    /// <typeparam name="T">Тип данных моделей, используемых в рамках CRUD-операций</typeparam>
    public abstract class BaseController<T>
        where T : IdModel
    {
        // Коллекция входных параметров
        protected IObjectContainer? _db;

        /// <summary>
        /// Конструктор абстрактного класса
        /// </summary>
        /// <param name="collection">Ссылка на коллекцию</param>
        public BaseController(IObjectContainer? db)
        {
            _db = db;
        }

        /// <summary>
        /// Метод для создания нового объекта коллекции
        /// </summary>
        /// <param name="data">Данные объекта коллекции</param>
        /// <returns>Созданный объект коллекции</returns>
        protected IResult Create(T data)
        {
            // Проверка подключения к базе данных
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Автоматическая генерация UUID
                data.Id = Guid.NewGuid().ToString();

                // Сохранение модели в ООДБ
                _db.Store(data);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }

        /// <summary>
        /// Метод для получения всех объектов определённого типа
        /// </summary>
        /// <returns>Результат работы функции (массив документов)</returns>
        public IResult GetAll()
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск всех данных по текущей модели
                IObjectSet result = _db.QueryByExample(typeof(T));

                // Возвращение списка моделей
                return Results.Json(result);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Метод для получения конкретного объекта из коллекции
        /// </summary>
        /// <param name="id">Идентификатор искомого объекта</param>
        /// <returns>Результат работы метода (конкретный объект)</returns>
        public IResult Get(string id)
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Получение конкретной модели
                T data = _db.Query<T>(value => value.Id == id)[0];

                return Results.Json(data);
            }
            catch (Exception)
            {
                return Results.Json(new MessageModel($"Модели с Id = {id} нет в ООБД"));
            }
        }

        /// <summary>
        /// Метод для удаления объекта из коллекции
        /// </summary>
        /// <param name="id">Идентификатор объекта</param>
        /// <returns>Удалённый объект коллекции</returns>
        public IResult Delete(string id)
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Получение конкретного объекта
                var data = _db.Query<T>(value => value.Id == id);
                if(data.Count <= 0)
                {
                    return Results.Json(new MessageModel($"Модели с Id = {id} нет в ООБД"));
                }

                var cloneData = data.First();

                // Удаление объекта
                _db.Delete(data.First());

                return Results.Json(cloneData);
            }
            catch (Exception)
            {
                return Results.Json(new MessageModel($"Модели с Id = {id} нет в ООБД"));
            }
        }
    }
}

При создании нового объекта на вход поступает объект типа T, который определяется классом через where: where T : IdModel , что позволяет обобщить CRUD-операции, ведь все модели, которые в них участвуют, наследуются от абстрактного класса IdModel, представляющую идентификатор модели.

Приведу пример реализации операции обновления из контроллера DataSourceController:

        /// <summary>
        /// Обновление объекта в коллекции
        /// </summary>
        /// <param name="data">Данные об объекте в коллекции</param>
        /// <returns>Обновлённый объект</returns>
        public IResult Update(DataSourceModel data)
        {
            // Проверка подключения к базе данных
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Поиск объекта по идентификатору
                DataSourceModel findObj = _db.Query<DataSourceModel>(value => value.Id == data.Id)[0];

                // Изменение данных в объекте
                findObj.Url = data.Url;
                findObj.Name = data.Name;

                // Фиксация изменений
                _db.Store(findObj);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }
Полный код контроллера DataSourceController
namespace oodb_project.controllers.db4o
{
    /// <summary>
    /// Класс определяющий контроллеры для коллекции объектов DataSource
    /// </summary>
    public class DataSourceController : BaseController<DataSourceModel>
    {
        public DataSourceController(IObjectContainer db) : base(db) { }

        /// <summary>
        /// Обновление объекта в коллекции
        /// </summary>
        /// <param name="data">Данные об объекте в коллекции</param>
        /// <returns>Обновлённый объект</returns>
        public IResult Update(DataSourceModel data)
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                DataSourceModel findObj = _db.Query<DataSourceModel>(value => value.Id == data.Id)[0];
                findObj.Url = data.Url;
                findObj.Name = data.Name;

                _db.Store(findObj);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }

            return Results.Json(data);
        }

        /// <summary>
        /// Создание нового объекта коллекции
        /// </summary>
        /// <param name="data">Данные об объекте</param>
        /// <returns>Созданный объект</returns>
        public new IResult Create(DataSourceModel data)
        {
            return base.Create(data);
        }

        /// <summary>
        /// Получение всех объектов коллекции с помощью SODA-запроса
        /// </summary>
        /// <returns>Список всех объектов коллекции</returns>
        public new IResult GetAll()
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                // Получение всех записей из DataSource с помощью SODA-запроса
                IQuery query = _db.Query();

                // Установка ограничений для поиска
                query.Constrain(typeof(DataSourceModel));

                // Получение результата поиска
                IObjectSet result = query.Execute();

                return Results.Json(result);
            }
            catch (Exception e)
            {
                return Results.Json(new MessageModel(e.Message));
            }
        }

        /// <summary>
        /// Каскадное удаление объекта DataSource
        /// </summary>
        /// <param name="id">Идентификатор объекта в коллекции</param>
        /// <returns>Удалённый объект</returns>
        public new IResult Delete(string id)
        {
            if (_db == null)
            {
                return Results.Json(new MessageModel("Подключение к ООБД отсутствует"));
            }

            try
            {
                var data = _db.Query<DataSourceModel>(value => value.Id == id);
                if (data.Count <= 0)
                {
                    return Results.Json(new MessageModel($"Модели с Id = {id} нет в ООБД"));
                }

                var services = _db.Query<ServiceModel>(value => value.DataSourceId == id);
                foreach (var item in services)
                {
                    var hostServices = _db.Query<HostServiceModel>(value => value.ServiceId == item.Id);
                    foreach(var hostService in hostServices)
                    {
                        _db.Delete(hostService);
                    }

                    _db.Delete(item);
                }

                var cloneData = data.First();

                // Удаление модели
                _db.Delete(data.First());

                return Results.Json(cloneData);
            }
            catch (Exception)
            {
                return Results.Json(new MessageModel($"Модели с Id = {id} нет в ООБД"));
            }
        }
    }
}

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

Разработка desktop-клиента (oodb-desktop-client)

В заключительной части статьи будут описаны результаты разработки desktop-клиента, который отправляет запросы на сервис oodb-mongo-server, через основной сервис oodb-main-server. То есть, клиент работает с базой данных MongoDB.

Файловая структура проекта

Рисунок 11 - Файловая структура oodb-desktop-client

В constants расположены все важные константы, которые используются в клиентском приложении.

В generators располагаются классы, определяющие методы, в которых происходит генерация объектов для конкретных коллекций.

В models расположены модели.

В services определены сервисы, с помощью которых реализуется логика взаимодействия с основным сервером oodb-main-server.

В utils находятся утилиты

Общий вид клиентского приложения

Рисунок 12 - Визуальное представление клиентского приложения

Приложение снабжено различными элементами управления, которые позволяют производить операции с конкретными коллекциями. На рисунке 12 представлено взаимодействие с коллекцией Host.

Разработка функционала

Устройство сервисов

Разберём устройство сервисов на примере AdminService.

namespace oodb_desktop_client.services
{
    /// <summary>
    /// Класс сервиса для коллекции объектов Admin
    /// </summary>
    public class AdminService : BaseService
    {
        public AdminService(DataGridView data) : base(data) {}

        /// <summary>
        /// Получение всех записей из таблицы Admins
        /// </summary>
        public void GetAll()
        {
            GetAll<AdminModel>(ApiUrl.ADMIN, "AdminId1");
        }

        /// <summary>
        /// Сохранение новой записи в таблицу Admins
        /// </summary>
        public void Save(AdminModel body)
        {
            Save(body, ApiUrl.ADMIN);
        }

        /// <summary>
        /// Обновление записи в таблице Admins
        /// </summary>
        /// <param name="body">Новые данные</param>
        public void Update(AdminModel body)
        {
            Update(body, ApiUrl.ADMIN, "AdminId1");
        }

        /// <summary>
        /// Удаление записи из таблицы Admins
        /// </summary>
        /// <param name="id">ID записи в ООБД</param>
        public void Delete(string id)
        {
            Delete<AdminModel>(id, ApiUrl.ADMIN, "AdminId1");
        }

        /// <summary>
        /// Формирование новой записи
        /// </summary>
        /// <param name="text"></param>
        /// <returns></returns>
        public void ShowDialog(
            string text,                // Текст
            string operation = "save",  // Операция (save / update)
            AdminModel oldValue = null  // Старое значение (опционально)
        ) 
        {
            // Создание экземпляра формы
            Form prompt = new Form()
            {
                Width = 300, // Ширина
                Height = 200, // Высота
                FormBorderStyle = FormBorderStyle.FixedDialog, // Стиль границы формы
                Text = text, // Текст формы
                StartPosition = FormStartPosition.CenterScreen // Стартовая позиция
            };

            // Текст перед строкой ввода
            Label textLabel = new Label() { Left = 100, Top = 20, Width = 100, Text = "Введите Email" };
            // Строка ввода данных
            TextBox textBox = new TextBox() { Left = 100, Top = 50, Width = 100, Text = (oldValue != null)? oldValue.Email : "" };
            // Кнопка отправки данных
            Button confirmation = new Button() { Text = text, Left = 100, Width = 100, Top = 90, DialogResult = DialogResult.OK };
            
            // Обработчик нажания на кнопку отправки
            confirmation.Click += (sender, e) => {
                prompt.Close();
            };

            // Добавление элементов управления на форму
            prompt.Controls.Add(textBox);
            prompt.Controls.Add(confirmation);
            prompt.Controls.Add(textLabel);
            prompt.AcceptButton = confirmation;

            if(prompt.ShowDialog() != DialogResult.OK)
            {
                return;
            }

            // Проверка валидности почтового адреса
            if (!ValidateUtil.IsValidEmail(textBox.Text))
            {
                MessageBox.Show(
                    "Не правильный формат Email-адреса",
                    "Ошибка",
                    MessageBoxButtons.OK,
                    MessageBoxIcon.Error
                );

                return;
            }
            
            // В зависимости от операции вызываем ту или иную функцию, с экземпляром модели
            if(operation == "save")
            {
                Save(new AdminModel(Guid.NewGuid().ToString(), textBox.Text));
            }else if(operation == "update")
            {
                Update(new AdminModel(oldValue.Id, textBox.Text));
            }
        }
    }
}

Первое, на что можно обратить внимание это наследование от класса BaseService. Данный класс реализует все CRUD-операции, основываясь на механизме рефлексии. С его помощью код сервисов значительно упрощается, однако необходимо определять метод ShowDialog(), который вызывается при изменении данных в объекте или создании нового объекта для коллекции (create или update), вызывая при этом форму. На рисунке 13 представлен такой вызов, однако для объекта коллекции Host.

Рисунок 13 - Вызываемая форма при изменении данных объекта в коллекции
Полное определение абстрактного класса BaseService
namespace oodb_desktop_client.services
{
    /// <summary>
    /// Абстрактный класс определяющий базовые CRUD-операции для работы с таблицами
    /// </summary>
    public abstract class BaseService
    {
        public DataGridView dataGridView;
        public BaseService(DataGridView data)
        {
            dataGridView = data;
        }

        /// <summary>
        /// Получение всех данных из таблицы
        /// </summary>
        /// <typeparam name="T">Тип данных</typeparam>
        /// <param name="domainPath">Путь к домену таблицы</param>
        /// <param name="columnName">Название столбца первичного ключа</param>
        public void GetAll<T>(string domainPath, string columnName) where T : IdModel
        {
            var url = $"{ApiUrl.BASE_URL}{ApiUrl.MONGO_DOMAIN}{domainPath}{ApiUrl.GET_ALL}";

            var httpRequest = (HttpWebRequest)WebRequest.Create(url);
            var httpResponse = (HttpWebResponse)httpRequest.GetResponse();

            using (var streamReader = new StreamReader(httpResponse.GetResponseStream()))
            {
                var result = streamReader.ReadToEnd();
                var list = JsonConvert.DeserializeObject<List<T>>(result.ToString());

                foreach (T item in list)
                {
                    if (GridViewUtil.GetIndexByValue(dataGridView, columnName, item.Id) < 0)
                    {
                        var fields = ReflectionUtil.getFields(item);

                        Action action = () => dataGridView.Rows.Add(fields.ToArray());
                        dataGridView.Invoke(action);
                    }
                }
            }

            httpResponse.Close();
        }

        /// <summary>
        /// Изменение данных
        /// </summary>
        /// <param name="body">Данные для изменения</param>
        public void Update<T>(T body, string domainPath, string columnName) where T : IdModel
        {
            var url = $"{ApiUrl.BASE_URL}{ApiUrl.MONGO_DOMAIN}{domainPath}{ApiUrl.UPDATE}";

            var httpRequest = (HttpWebRequest)WebRequest.Create(url);
            httpRequest.Method = "POST";
            httpRequest.ContentType = "application/json";

            var data = JsonConvert.SerializeObject(body);

            using (var streamWriter = new StreamWriter(httpRequest.GetRequestStream()))
            {
                streamWriter.Write(data);
            }

            var httpResponse = (HttpWebResponse)httpRequest.GetResponse();
            object output = null;

            using (var streamReader = new StreamReader(httpResponse.GetResponseStream()))
            {
                var result = streamReader.ReadToEnd();
                output = (object)JsonConvert.DeserializeObject<T>(result.ToString());

                if (((T)output).Id == null)
                {
                    MessageBox.Show(
                        (JsonConvert.DeserializeObject<MessageModel>(result.ToString())).message,
                        "Ошибка",
                         MessageBoxButtons.OK,
                        MessageBoxIcon.Error
                    );
                }
                else
                {
                    T model = ((T)output);
                    var index = GridViewUtil.GetIndexByValue(dataGridView, columnName, model.Id);
                    var fields = ReflectionUtil.getFields(model);

                    Action action = () =>
                    {
                        for (var i = 0; i < fields.Count; i++)
                        {
                            dataGridView.Rows[index].Cells[i].Value = fields[i];
                        }
                    };

                    dataGridView.Invoke(action);
                }
            }

            httpResponse.Close();
        }

        /// <summary>
        /// Сохранение данных
        /// </summary>
        /// <param name="body">Данные для сохранения</param>
        public void Save<T>(T body, string domainPath) where T : IdModel
        {
            var url = $"{ApiUrl.BASE_URL}{ApiUrl.MONGO_DOMAIN}{domainPath}{ApiUrl.SAVE}";

            var httpRequest = (HttpWebRequest)WebRequest.Create(url);
            httpRequest.Method = "POST";
            httpRequest.ContentType = "application/json";

            var data = JsonConvert.SerializeObject(body);

            using (var streamWriter = new StreamWriter(httpRequest.GetRequestStream()))
            {
                streamWriter.Write(data);
            }

            var httpResponse = (HttpWebResponse)httpRequest.GetResponse();
            object output = null;

            using (var streamReader = new StreamReader(httpResponse.GetResponseStream()))
            {
                var result = streamReader.ReadToEnd();
                output = (object)JsonConvert.DeserializeObject<T>(result.ToString());

                if (((T)output).Id == null)
                {
                    MessageBox.Show(
                        (JsonConvert.DeserializeObject<MessageModel>(result.ToString())).message,
                        "Ошибка",
                         MessageBoxButtons.OK,
                        MessageBoxIcon.Error
                    );
                }
                else
                {
                    var fields = ReflectionUtil.getFields((T)output);

                    Action action = () => dataGridView.Rows.Add(fields.ToArray());
                    dataGridView.Invoke(action);
                }
            }

            httpResponse.Close();
        }

        /// <summary>
        /// Удаление записи
        /// </summary>
        /// <param name="admin">Данные для сохранения</param>
        public void Delete<T>(string id, string domainPath, string columnId) where T : IdModel
        {
            var url = $"{ApiUrl.BASE_URL}{ApiUrl.MONGO_DOMAIN}{domainPath}{ApiUrl.DELETE}/{id}";

            var httpRequest = (HttpWebRequest)WebRequest.Create(url);
            httpRequest.Method = "POST";
            httpRequest.ContentType = "application/json";

            var httpResponse = (HttpWebResponse)httpRequest.GetResponse();
            object output = null;

            using (var streamReader = new StreamReader(httpResponse.GetResponseStream()))
            {
                var result = streamReader.ReadToEnd();
                output = (object)JsonConvert.DeserializeObject<T>(result.ToString());

                if (((T)output).Id == null)
                {
                    MessageBox.Show(
                        (JsonConvert.DeserializeObject<MessageModel>(result.ToString())).message,
                        "Ошибка",
                         MessageBoxButtons.OK,
                        MessageBoxIcon.Error
                    );
                }
                else
                {
                    var index = GridViewUtil.GetIndexByValue(dataGridView, columnId, id);
                    Action action = () => dataGridView.Rows.RemoveAt(index);
                    dataGridView.Invoke(action);
                }
            }

            httpResponse.Close();
        }
    }
}

Генерация данных с использованием Bogus

Для генерации данных из клиента была использована библиотека Bogus, которую можно установить с помощью пакетного менеджера NuGet.

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

namespace oodb_desktop_client.generators
{
	/// <summary>
	/// Класс генератора объектов коллекции Admin
	/// </summary>
	public class AdminGenerator
    {
		/// <summary>
		/// Сервис Admin
		/// </summary>
        private AdminService _adminService;

        public AdminGenerator(AdminService adminService)
        {
            _adminService = adminService;
        }

		/// <summary>
		/// Генерация администраторов
		/// </summary>
		/// <param name="count">Количество генерируемых записей</param>
		/// <param name="progress">Прогресс генерации</param>
		public void GenerateAdmin(
			int count,							// Количество сгенерированных элементов
			IProgress<ProgressInfo> progress    // Прогресс генерации
		)
		{
			// Создание экземпляра класса Faker
			var faker = new Faker("ru");

			// Процесс генерации объектов
			for(var i = 0; i < count; i++)
            {
				// Получение рандомного почтового адреса
				var email = faker.Internet.Email();

				// Сохранение объекта в ООБД
				_adminService.Save(new AdminModel("", email), ApiUrl.ADMIN);

				// Обновление текстовой информации
				var info = $"Сгенерировано записей: {i}";

				// Отправка изменений о прогрессе генерации записей
				progress?.Report(new ProgressInfo
				{
					value = i + 1,
					info = info
				});

				// Пауза
				Thread.Sleep(1);
			}
		}
	}
}

Генерация определённого количества записей происходит в методе GenerateAdmin. В данном методе используется экземпляр объекта Faker из Bogus, который генерирует случайный почтовый адрес пользователя, а затем через сервис AdminService происходит сохранение сгенерированной модели в базу данных.

Ссылка на репозиторий проекта.

Выводы

В данной статье были рассмотрены результаты разработки системы, состоящей из множества сервисов, каждый из которых работает со своей базой данных.

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

Спроектированная архитектура системы полностью соответствует полученным результатам.

Два сервиса осуществляют работу с объектно-ориентированными базами данных Perst и db4o, один сервис взаимодействует с MongoDB, которая была развёрнута в контейнере вместе с веб-интерфейсом Mongo Express.

Клиентское приложение было разработано с помощью .NET Framework 4.8. Оно взаимодействует с основным сервисом (oodb-main-server) и посредством этого взаимодействия получает доступ к сервису oodb-mongo-server.

Было также дано описание проблеме с поддержкой Perst новыми версиями .NET, и описание решения данной проблемы в контексте избранного архитектурного паттерна (микросервисная архитектура).

Список использованных источников

  1. Репозиторий основного сервиса: oodb-main-server

  2. Репозиторий сервиса для взаимодействия с Perst: oodb-perst-server

  3. Репозиторий сервиса для взаимодействия с MongoDB: oodb-mongo-server

  4. Репозиторий клиентского приложения: oodb-desktop-client

Список рекомендуемых источников

  1. Введение в объектно-ориентированные базы данных

  2. Perst - высокопроизводительная ООБД

  3. Создание Web API приложения с использованием .NET Core + MongoDB .NET Driver

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


  1. Lukerman
    00.00.0000 00:00

    Тестовое?

    Подскажите пожалуйста, может я не внимательно прочитал статью и поэтому не понял для чего вы пишете блокирующий код ?

    P.S. За материал + ,аккуратность , оформление.


    1. dan_sw Автор
      00.00.0000 00:00
      +1

      Доброе утро.

      Да, здесь используется механизм блокирующего кода. Устройство контроллеров организованно таким образом, что они в большинстве своём используют в качестве возвращаемого типа IResult, а не Task<IResult>, что было бы уместно при реализации механизма неблокирующего кода.

      Такое "упрощение" механизма контроллеров было сделано с допущением, что данный проект по большей части демонстрационный, однако при использовании исходников данного проекта конечно стоит учитывать дополнение всех контроллеров возвращаемым типом Task<IResult>.

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