Как сделать так, чтобы код Backend стажера не вонял
Код, разобранный в статье, можно посмотреть в этом репозитории
ООП это про мусорные пакеты для плохого кода. Любой код становится плохим в длинной временной перспективе, однако, если обернуть его в интерфейс, он не воняет. Лучшее ООП реализовано в 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" }