Как стать автором
Обновить

Реализация SOLID и слоистой архитектуры в Node.js с TypeScript и InversifyJS

Время на прочтение15 мин
Количество просмотров16K
Автор оригинала: Remo H. Jansen

Привет, Хабр! Предлагаю вашему вниманию перевод статьи Implementing SOLID and the onion architecture in Node.js with TypeScript and InversifyJS автора Remo H. Jansen


В этой статье мы рассмотрим архитектуру, известную как слоистая (onion). Слоистая архитектура — подход к построению архитектуры приложения, придерживающийся принципов SOLID. Он создан под влиянием DDD и некоторых принципов функционального программирования, а также, активно применяет принцип инъекции зависимостей.


Предпосылки


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


Принцип разделения ответственности


Под ответственностью подразумеваются различные аспекты функционала программного обеспечения. Например "бизнес-логика" и интерфейс, через который она используется — это разные ответственности.


Разделение ответственности позволяет изолировать код, реализующий каждую ответственность.Например, изменение интерфейса не должно требовать изменения кода бизнес-логики, и так далее.


Принципы SOLID


SOLID является акронимом от следующих пяти принципов:
image


Принцип единственной ответственности


Класс должен иметь только одну ответственность. (прим. переводчика: более точная формулировка, на мой взгляд, звучит так: "Класс должен иметь одну и только одну причину для изменений")

Самый эффективный способ "сломать" приложение — создание божественного класса.


Божественный класс — класс, знающий и делающий слишком много. Этот подход является хорошим примером анти-паттерна.

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


Следующий пример представляет класс TypeScript описывающий персону. Этот класс не должен включать валидацию email, так как она не относится к поведению персоны.


class Person {
    public name : string;
    public surname : string;
    public email : string;
    constructor(name : string, surname : string, email : string){
        this.surname = surname;
        this.name = name;
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
    greet() {
        alert("Hi!");
    }
}

Мы можем улучшить этот класс путем вынесения ответственности за валидацию email в новый класс Email:


class Email {
    public email : string;
    constructor(email : string){
        if(this.validateEmail(email)) {
          this.email = email;
        }
        else {
            throw new Error("Invalid email!");
        }        
    }
    validateEmail(email : string) {
        var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i;
        return re.test(email);
    }
}

class Person {
    public name : string;
    public surname : string;
    public email : Email;
    constructor(name : string, surname : string, email : Email){
        this.email = email;
        this.name = name;
        this.surname = surname;
    }
    greet() {
        alert("Hi!");
    }
}

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


Принцип открытости/закрытости


Программные сущности должны быть открыты для расширения и закрыты для модификации.

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


class Rectangle {
    public width: number;
    public height: number;
}

class Circle {
    public radius: number;
}

function getArea(shapes: (Rectangle|Circle)[]) {
    return shapes.reduce(
        (previous, current) => {
            if (current instanceof Rectangle) {
                return current.width * current.height;
            } else if (current instanceof Circle) {
                return current.radius * current.radius * Math.PI;
            } else {
                throw new Error("Unknown shape!")
            }
        },
        0
    );
}

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


Для решения данной проблемы, мы можем использовать преимущества полиморфизма в ООП, например так:


interface Shape {
    area(): number;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}

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


Принцип подстановки Барбары Лисков


Объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы.

Данный принцип, также, призывает нас использовать полиморфизм. В предыдущем примере кода:


function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}

Мы использовали интерфейс Shape для уверенности в том, что наша программа открыта к расширению и закрыта к модификации. Принцип подстановки Барбары Лисков говорит нам, что мы должны иметь возможность передать в функцию getArea экземпляр любого класса, реализующего интерфейс Shape без влияния на работоспособность программы. В языках со статической типизацией, таких как TypeScript, компилятор проверяет корректность имплементации подтипов (например, если в имплементация интерфейса Shape будет отсутствовать метод area, возникнет ошибка компиляции. Это означает, что мы не должны делать много ручной работы для того, чтобы быть уверенными в соответствии кода принципу подстановки Барбары Лисков.


Принцип разделения интерфейса


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

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


interface Shape {
    area(): number;
    serialize(): string;
}

class Rectangle implements Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }

    public serialize() {
        return JSON.stringify(this);
    }
}

class Circle implements  Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }

    public serialize() {
        return JSON.stringify(this);
    }

}

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


function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}

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


// ...
return rectangle.serialize();

Проблема в том, что добавление метода serialize в интерфейс Shape нарушает принципы разделения ответственности и единственной ответственности. Фигура является бизнес-концепцией, а его сериализация — инфраструктурной концепцией. Мы не должны смешивать эти концепции в одном интерфейсе.


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


interface RectangleInterface {
    width: number;
    height: number;
}

interface CircleInterface {
    radius: number;
}

interface Shape {
    area(): number;
}

interface Serializable {
    serialize(): string;
}

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


class Rectangle implements RectangleInterface, Shape {

    public width: number;
    public height: number;

    public area() {
        return this.width * this.height;
    }
}

class Circle implements CircleInterface, Shape {

    public radius: number;

    public area() {
        return this.radius * this.radius * Math.PI;
    }
}

function getArea(shapes: Shape[]) {
    return shapes.reduce(
        (previous, current) => previous + current.area(),
        0
    );
}

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


class RectangleDTO implements RectangleInterface, Serializable {
    public width: number;
    public height: number;

    public serialize() {
        return JSON.stringify(this);
    }
}

class CircleDTO implements CircleInterface, Serializable {
    public radius: number;

    public serialize() {
        return JSON.stringify(this);
    }
}

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


Мы можем спорить о том, что RectangleDTO и Rectangle почти идентичны и был нарушен принцип Не повторяйся (DRY). Я думаю, что это другой случай. Потому что, эти классы выглядят похожими, но являются выражением разных концепций. Далеко не всегда, похожий код является дублированием.


Даже в случае нарушения принципа DRY, мы будем выбирать между нарушением DRY или SOLID. Я считаю, что принцип DRY менее важен, нежели принципы SOLID и я предпочту "повторить себя" в таком случае.


Принцип инверсии зависимостей


Зависимость на Абстракциях. Нет зависимости на что-то конкретное.

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


К сожалению, принцип инверсии зависимостей представлен в аббревиатуре SOLID буквой D. И всегда к объяснению этого принципа переходят в последнюю очередь, несмотря на то, что он является самым важным в SOLID. Без применения этого принципа, большинство других принципов SOLID применять невозможно. Если мы оглянемся назад и вспомним все принципы, которые мы затронули выше, мы придем к выводу, что использование интерфейсов является ключевым элементом в каждом принципе:


  • Зависимость на интерфейсы, которая следует из принципа разделения ответственности, позволяет нам изолировать одни уровни приложения от деталей реализации других и помогает избежать нарушения принципа единственной ответственности.
  • Использование интерфейсов позволяет нам заменять одну реализацию другой (принцип подстановки Барбары Лисков).
  • С использованием интерфейсов мы можем создавать приложения, открытые к расширению, но закрытые к модификации (принцип открытости/закрытости).

Реализация принципов SOLID в языках программирования. которые не поддерживают интерфейсы или в программных парадигмах, не поддерживающих полиморфизм, является очень неестественным. Например, в JavaScript ES5 или даже ES6, реализация SOLID может быть крайне неестественной. Тем не менее, в TypeScript это может быть реализовано вполне естественно.


Шаблон проектирования модель-представление-контроллер MVC


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


image


Модель


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


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


Представление


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


Контроллер


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


Подход MVC позволяет создавать приложения с разделением различных аспектов (логика ввода, бизнес логика и логика отображения) и обеспечивая низкую связность этих частей. Данных подход определяет, где должна быть расположена каждая часть логики приложения. Логика пользовательского интерфейса располагается в представлении. Логика ввода — в контроллере. Бизнес-логика — в модели. Это разделение позволяет управлять сложностью в разработке приложения, так как заставляет фокусироваться на одном аспекте и не думать об остальных. Например, вы можете сфокусироваться только на представлении, без зависимости от бизнес-логики.


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


Репозиторий и дата-маппер


Паттерн MVC помогает нам разделить логику ввода, бизнес-логику и логику пользовательского интерфейса. Тем не менее, в зоне ответственности модели остаётся слишком много аспектов. Мы можем использовать паттерн Репозиторий для отделения логики получения и маппинга данных на сущности модели от бизнес-логики, которая непосредственно работает с моделью. Бизнес-логика должна быть независимой по отношению к типам данных, которые хранятся на уровне источника данных. Например, источником данных может быть база данных, файловая система или веб-сервис.


Репозиторий является посредником между слоем данных и слоем бизнес-логики приложения. Он запрашивает нужные данные и маппит их на бизнес-сущность, а также, сохраняет изменения бизнес-сущности в слой данных. Репозиторий отделяет бизнес-логику от взаимодействия с нижележащими слоями данных. Это разделение между слоями данных и бизнес-логики имеет три преимущества:


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

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


image


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


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


Паттерн Репозиторий помогает избавить своих клиентов от зависимости на конкретные технологии хранения данных. Например, если клиент вызывает репозиторий catalog для получения данных определенного товара, ему необходимо знать только интерфейс этого репозитория. В этом случае, клиенту не нужно ничего знать о способе получения данных репозиторием, будь то SQL-запрос к базе данных или запрос к Sharepoint с использованием Языка разметки совместных приложений (CAML). Подобная изоляция зависимостей обеспечивает гибкость в развитии конкретных реализаций.


Слоистая архитектура


Данный подход делит приложение на слои (подобно луковице):



Центральный слой — доменная модель. По мере движения от центрального слоя к внешним, мы можем увидеть доменные сервисы, сервисы уровня приложения и, наконец, слои тестов, инфраструктуры и пользовательского интерфейса.


В DDD, в центре всего находится "Домен". Домен состоит из двух компонентов:


  • Доменная модель
  • Доменные сервисы

В функциональном программировании, одним из главных архитектурных принципов, является вынесение всех сайд-эффектов к границам приложения. Слоистая архитектура также следует этому принципу. Ядро приложения (доменные сервисы и доменная модель) должны быть быть свободны от сайд-эффектов и деталей реализации. Это означает, что в нём не должно быть ссылок на такие вещи, как детали реализации хранения информации (например SQL) или передачи данных (например HTTP).


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


Изоляция слоёв достигается использованием интерфейсов и применением принципа инверсии зависимостей: Компоненты должны зависеть на абстракции (интерфейсы), а не на конкретные реализации (классы). В качестве примера, рассмотрим один из инфраструктурных слоёв — HTTP слой, в основном, построенный из набора контроллеров. Контроллер AircraftController может иметь зависимость от интерфейса AircraftRepository:


import { inject } from "inversify";
import { response, controller, httpGet } from "inversify-express-utils";
import * as express from "express";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entities/aircraft";
import { TYPE } from "@domain/types";

@controller("/api/v1/aircraft")
export class AircraftController {

    @inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;

    @httpGet("/")
    public async get(@response() res: express.Response) {
        try {
            return await this._aircraftRepository.readAll();
        } catch (e) {
            res.status(500).send({ error: "Internal server error" });
        }

    }

    // ...

}

AircraftController является частью инфраструктурного слоя и его основная ответственность заключается в работе с вопросами связанными с HTTP и он делегирует часть работы интерфейсу AircraftRepository. Реализация интерфейса AircraftRepository должна быть полностью изолирована от HTTP. Сейчас наш граф зависимостей выглядит так:


image


Стрелки на этой диаграмме имеют разные значения. Стрелка "comp" (composition) определяет, что AircraftRepository является свойством AircraftController. Стрелка "ref" (reference) определяет, что AircraftController ссылается или зависит от Aircraft.


Интерфейс AircraftRepository является частью доменных сервисов, в то время, как AircraftController и реализация AircraftRepository, являются частями инфраструктурного слоя:


image


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


image


Мы используем интерфейс AircraftRepository для разделения доменного и инфраструктурного слоёв уже на этапе проектирования. Тем не менее, во время выполнения программы, эти два слоя должны быть каким-то образом соединены. Такое "соединение" между интерфейсами и их реализациями может управляться InversifyJS. Библиотека InversifyJS позволяет, при помощи декоратора @inject, объявлять зависимости для инъекции. На этапе проектирования, мы можем определить необходимость инъекции реализации интерфейса:


@inject(TYPE.AircraftRepository) private readonly _aircraftRepository: AircraftRepository;

На этапе работы приложения, InversifyJS использует свою конфигурацию для инъекции конкретной реализации:


container.bind<AircraftRepository>(TYPE.AircraftRepository).to(AircraftRepositoryImpl);

Теперь рассмотрим интерфейсы AircratRepository и Repository<T>, являющиеся частями слоя доменных сервисов.


import { Aircraft } from "@domain/entities/aircraft";

export interface Repository<T> {
    readAll(): Promise<T[]>;
    readOneById(id: string): Promise<T>;
    // ...
}

export interface AircraftRepository extends Repository<Aircraft> {
    // Add custom methods here ...
}

Сейчас наш граф зависимостей выглядит таким образом:


image


Рассмотрим реализацию интерфейсов Repository<T> и AircraftRepository:


  • Repository<T> будет реализован классом Gene- ricRepositoryImpl<D, E>
  • AircraftRepository будет реализован классом AircraftRepositoryImpl.

Начнём с реализации Repository<T>


import { injectable, unmanaged } from "inversify";
import { Repository } from "@domain/interfaces";
import { EntityDataMapper } from "@dal/interfaces";
import { Repository as TypeOrmRepository } from "typeorm";

@injectable()
export class GenericRepositoryImpl<TDomainEntity, TDalEntity> implements Repository<TDomainEntity> {

    private readonly _repository: TypeOrmRepository<TDalEntity>;
    private readonly _dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>;

    public constructor(
        @unmanaged() repository: TypeOrmRepository<TDalEntity>,
        @unmanaged() dataMapper: EntityDataMapper<TDomainEntity, TDalEntity>
    ) {
        this._repository = repository;
        this._dataMapper = dataMapper;
    }

    public async readAll() {
        const entities = await this._repository.readAll();
        return entities.map((e) => this._dataMapper.toDomain(e));
    }

    public async readOneById(id: string) {
        const entity = await this._repository.readOne({ id });
        return this._dataMapper.toDomain(entity);
    }

    // ...

}

Эта реализация ожидает, что EntityDataMapper и TypeOrmRepository будут внедрены через конструктор. Эти зависимости будут использованы для получения данных из БД и маппинга их в доменные сущности.


Также, нам нужен интерфейс EntityDataMapper:


export interface EntityDataMapper<Domain, Entity> {

    toDomain(entity: Entity): Domain;
    toDalEntity(domain: Domain): Entity;
}

И реализация EntityDataMapper:


import { toDateOrNull, toLocalDateOrNull } from "@lib/universal/utils/date_utils";
import { Aircraft } from "@domain/entities/aircraft";
import { AircraftEntity } from "@dal/entities/aircraft";
import { EntityDataMapper } from "@dal/interfaces";

export class AircraftDataMapper implements EntityDataMapper<Aircraft, AircraftEntity> {

    public toDomain(entity: AircraftEntity): Aircraft {
        // ...
    }

    public toDalEntity(mortgage: Aircraft): AircraftEntity {
        // ...
    }
}

Мы используем EntityDataMapper для маппинга сущностей, полученных от TypeOrmRepository в доменные сущности.Теперь наша диаграмма зависимостей выглядит следующим образом:


image


И, наконец, реализуем AircraftRepository:


import { inject, injectable } from "inversify";
import { Repository as TypeOrmRepository } from "typeorm";
import { AircraftRepository } from "@domain/interfaces";
import { Aircraft } from "@domain/entities/aircraft";
import { GenericRepositoryImpl } from "@dal/generic_repository";
import { AircraftEntity } from "@dal/entities/aircraft";
import { AircraftDataMapper } from "@dal/data_mappers/aircraft";
import { TYPE } from "@dal/types";

@injectable()
export class AircraftRepositoryImpl
    extends GenericRepositoryImpl<Aircraft, AircraftEntity>
    implements AircraftRepository {

    public constructor(
        @inject(TYPE.TypeOrmRepositoryOfAircraftEntity) repository: TypeOrmRepository<AircraftEntity>
    ) {
        super(repository, new AircraftDataMapper())
    }

    // Add custom methods here ...

}

Теперь, когда мы закочили, наш граф зависимостей выглядит так:


image


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


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


image


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


Я надеюсь, Вам понравилась эта статья! Пожалуйста, делитесь своими мыслями в комментариях или напрямую автору @RemoHJansen.


Бонус для тех, кто дочитал до конца — репозиторий с рабочим примером кода.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+11
Комментарии6

Публикации

Истории

Работа

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн