Как сделать так, чтобы код Backend стажера не вонял

Код, разобранный в статье, можно посмотреть в этом репозитории

5d63d004ee428eb17dada9244d1d0a12.jpg

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

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

Со ссылками на аналогичные подходы из C# я выделил несколько практик, позволяющих улучшить качество кода до ревью

Инъекция зависимостей

В SOLID для инъекции зависимостей выделена отдельная буква. Это не просто так: соединять существующий код проще, когда он изначально создается как конструктор Lego: кубики стандартизируются скорее слотами соединения, а не своей формой.

config/TYPES.ts

const baseServices = {
    loggerService: Symbol('loggerService'),
    contextService: Symbol('contextService'),
};

const dbServices = {
    dataDbService: Symbol('dataDbService'),
};

const viewServices = {
    dataViewService: Symbol('dataViewService'),
};

const TYPES = {
    ...baseServices,
    ...dbServices,
    ...viewServices,
};

export default TYPES;

config/provide.ts

import { provide } from 'di-kit';
import TYPES from './types';

{
    provide(TYPES.loggerService, () => new LoggerService());
    provide(TYPES.contextService, () => new ContextService());
}

{
    provide(TYPES.dataDbService, () => new DataDbService());
}

{
    provide(TYPES.dataViewService, () => new DataViewService());
}

index.ts

import { inject, init } from 'di-kit';
import TYPES from './config/types';

const baseServices = {
    loggerService: inject(TYPES.loggerService),
    contextService: inject(TYPES.contextService),
};

const dbServices = {
    dataDbService: inject(TYPES.dataDbService),
};

const viewServices = {
    dataViewService: inject(TYPES.dataViewService),
};

const ioc = {
    ...baseServices,
    ...dbServices,
    ...viewServices,
};

init();

Важно соблюсти три критерия

1. Не использовать синтаксис декораторов

Декораторы изначально это экспериментальный синтаксис TypeScript, поэтому, его не получится прозрачно перенести в REPL или точно проанализировать поведение языковой моделью, так как, когда-нибудь TC39 проведут proposal и весь код будет deprecated

2. Использовать контейнер, умеющий разрешать циклические зависимости

Циклическая зависимость это ошибка, однако, иногда класс нужно разнести на две части. В C# для этого выделено отдельное ключевое слово partial. Вам точно потребуется, когда вы захотите отделить запросы авторизации/регистрации и продления от сервиса хранения и проверки JWT токена.

3. Инъекция зависимостей не должна зависеть от бандлера

Использования компилятора зависимостей не даст запустить проект в режиме интерпретации через ts-note, Bun или Deno

Scoped сервисы

В C#, для создания сервисов инстанцируемых в контексте исполнения HTTP запроса, используются Transient services

services/base/ContextService.ts

import { scoped } from "di-scoped";

export interface IContext {
    serviceName: string;
    clientId: string;
    userId: string;
    requestId: string;
}

export const ContextService = scoped(
    class {
        constructor(readonly context: IContext) {}
    }
);

export type TContextService = InstanceType;

export default ContextService;

services/base/LoggerService.ts

import { log } from 'pinolog';
import { inject } from 'di-kit';
import { TContextService } from './ContextService';
import TYPES from 'src/config/types';

export class LoggerService {
    protected readonly contextService = inject(TYPES.contextService);

    public log = (...args: any[]) => {
        log(...args);
    }

    public logCtx = (...args: any[]) => {
        log(...args, this.contextService.context);
    };
}

export default LoggerService;

services/view/DataViewService.tsx

import { log } from 'pinolog';
import { inject } from 'di-kit';
import { LoggerService } from '../base/LoggerService';
import DataDbService from '../db/DataDbService';
import ContextService, { IContext } from '../base/ContextService';
import TYPES from 'src/config/types';

export class DataViewService {
    readonly loggerService = inject(TYPES.loggerService);
    readonly dataDbService = inject(TYPES.dataDbService);

    public findById = async (id: string, context: IContext) => {

        return await ContextService.runInContext(async () => {

            return await this.dataDbService.findById(id);

        }, context);
    }

}

export default DataViewService;

Самое важное в плохом коде — хорошее логирование. Для читаемости логов, нужно фиксировать serviceName,  clientId,  userId и requestId. Код не будет чистым, если передавать эти данные через аргументы функций, как минимум, стажеры обязательно где-нибудь потеряют одно из значений. Грамотно реализовать логирование можно через loggerService.logCtx, где переменная context берется из контекста исполнения async_hooks

Дополнительно, используя scoped сервисы, можно передавать токен пользователя для взаимодействия с интеграциями, например, Appwrite

Паттерн Generic Repository (BaseCRUD)

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

common/BaseCRUD.ts

import { factory } from "di-factory";
import { Model } from "mongoose";

export const BaseCRUD = factory(
    class {
        constructor(public readonly TargetModel: Model) {}

        public async create(dto: object) {
            const passenger = await this.TargetModel.create(dto);
            return passenger.toJSON();
        }

        public async findById(id: string) {
            const passenger = await this.TargetModel.findById(id);
            if (!passenger) {
                throw new Error(`${this.TargetModel.modelName} not found`);
            }
            return passenger.toJSON();
        }

        public async paginate(
            filterData: object,
            pagination: {
                limit: number;
                offset: number;
            }
        ) {
            const documents = await this.TargetModel.find(filterData)
                .skip(pagination.offset)
                .limit(pagination.limit);
            const total = await this.TargetModel.countDocuments(filterData);
            return {
                rows: documents.map((item) => item.toJSON()),
                total: total,
            };
        }
    }
);

export default BaseCRUD;

services/db/DataDbService.ts

import { TBasePaginator } from "functools-kit";
import BaseCRUD from "src/common/BaseCRUD";
import { inject } from "src/core/di";
import {
    DataModel,
    Data,
    DataRow,
    DataFilterData,
} from "src/schema/Data.schema";
import LoggerService from "../base/LoggerService";
import TYPES from "src/config/types";

export class DataDbService extends BaseCRUD(DataModel) {
    public readonly loggerService = inject(TYPES.loggerService);

    public create = async (dto: Data) => {
        this.loggerService.logCtx(`dataDbService create`, { dto });
        return await super.create(dto);
    };

    public findById = async (id: string) => {
        this.loggerService.logCtx(`dataDbService findById`, { id });
        return await super.findById(id);
    };
}

export default DataDbService;

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

interface IDataPublicService extends DataPrivateService {}

export type TDataPublicService = {
    [key in Exclude]: any;
};

// Удобно для View сервиса, подсветит, что метод из Db сервиса не обернут
class DataViewService implements TDataPublicService {  }

Stateless классы

Если необходимо написать аналитику по базе данных, стажер обязательно попытается выкачать базу в Array в поле класса. Чтобы ли чем объяснять, почему это неправильно, проще поставить задачу сразу писать такой класс как stateless: на каждый вызов метода создается обособленная инстанция класса, по завершению исполнения метода поля класса стираются.

import { stateless } from 'di-stateless';

const StatelessService = stateless(
    class {
        randomId = randomString();

        constructor() {
            console.log("StatelessService CTOR");
        }

        methodFoo = () => this.randomId;
        methodBaz = () => this.randomId;
        methodBar = () => this.randomId;

        entry = () => console.log({
            foo: this.methodFoo(),
            bar: this.methodBar(),
            baz: this.methodBaz(),
        });
    }
);

const service = new TransientService();

service.entry(); // { foo: "ndjol", bar: "ndjol", baz: "ndjol" }

service.randomId = "not-random-id";

service.entry(); // { foo: "c2wiyf", bar: "c2wiyf", baz: "c2wiyf" }

Спасибо за внимание!

© Habrahabr.ru