Если вы читаете это, вы, вероятно, хотя бы раз писали серверный код. Вам когда-нибудь было трудно добавить небольшую функцию в систему? Требуется ли все больше и больше времени для внедрения новых функций или исправления ошибок? Возможно, вы неправильно заложили основу. Позвольте мне показать вам проверенный в бою способ создания серверного кода, который упрощает модификацию и расширение вашей системы в дальнейшем. Вам может быть интересно, какое отношение это имеет к названию — смысл в том, чтобы переключить внимание с веб-фреймворков на моделирование реальности бизнеса, для которого вы пишете код. Я собираюсь показать вам сочетание того, что я извлек и нашел действительно полезным из некоторых методологий, в первую очередь DDD, TDD и Чистая архитектура Роберта С. Мартина. ”». Цель этого — представить подход, который смешивает их с опытом разработчиков в typescript и nodejs.
Примечание. Я предполагаю, что вы создаете приложение среднего размера. Этот подход не имеет смысла, если вы создаете всего несколько конечных точек, а если вы создаете что-то действительно большое, вам, вероятно, следует использовать что-то более дисциплинированное.
1. Закладка фундамента
Нам нужно начать со структуры файла. Полезно разделить наш проект на 4 слоя. Я кратко представлю их, но не волнуйтесь, если вы еще не понимаете — вы увидите примеры кода ниже.
- Доменный уровень
На этом уровне код должен быть моделью реальности. Вот и все. Уровень предметной области не должен иметь никаких знаний о базах данных, платформах MVC и т. д.
- Прикладной уровень
Этот слой содержит варианты использования нашей системы — например. a user logs in
или a user changes password
- короче говоря, он координирует объекты из уровня домена и инфраструктуры.
- Уровень инфраструктуры
Здесь идет наше подключение к базе данных, схемы базы данных и т. д. — также очень общие вещи, такие как ведение журнала, хеширование, сторонние API и т. д.
- Уровень драйвера
Этот слой является связующим звеном между нашей системой и внешним миром — здесь находятся наши экспресс-контроллеры, прослушиватели веб-сокетов и другие подобные вещи.
2. Файлы проекта
Хорошо, с этими знаниями мы можем настроить основные файлы нашего проекта:
__tests__ src -/driver -/application -/domain -/infrastructure package.json tsconfig.json
Когда дело доходит до tsconfig.json
, постарайтесь сделать его максимально строгим (ну, почти) — включите следующие флаги:
"noImplicitAny": true , "strictNullChecks": true, "strictFunctionTypes": true , "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true ,
3. Наши потребности
Мы готовы начать писать наш код. Мы создадим сервис для хранения и аутентификации пользователей.
Поскольку предметная область довольно проста, давайте сразу перейдем к определению требований:
- Пользователь регистрируется
- Пользователь входит в систему
- Пользователь хочет получить свои данные
Это примеры вариантов использования системы. Начнем с регистрации пользователя.
Нам обязательно понадобится что-то, что представляет собой User
. Давайте напишем тест для класса, делающего именно это:
// __tests__/User.ts import { User } from "../src/domain/User"; describe("User", () => { describe("User creation", async () => { it("Should allow you to create a user", () => { const user = await User.create({ password: "12345678", email: "[email protected]", }); expect(user).toBeTruthy(); }); it(`Should NOT allow you to create a user with invalid data`, () => { expect( User.create({ password: "12345678", email: "This is not a valid email", }) ).rejects.toThrowErrorMatchingInlineSnapshot(); }); }); });
Тестов должно быть больше, но давайте их пока пропустим.
Теперь, когда мы знаем, что нам понадобится от класса User
, давайте напишем сам класс:
export default class User { private constructor( private readonly props: { email: string; password: string; } ) {} }
Вы можете задаться вопросом, почему мы используем private constructor
— это сделано для того, чтобы будущие программисты (пользователи кода) не могли обойти проверку данных. Мы разрешаем создавать экземпляр User
только с допустимыми данными.
Давайте, наконец, добавим этот механизм создания:
export default class User { private constructor( private readonly props: { email: string; password: string; } ) {}; static async create(userInit: { email: string; password: string; }) { if(!userInitSchema.isValid(userInit)) throw new Error("ERR_INVALID_USER_INIT"); return new User({ email: userInit.email, password: userInit.password }) } }
Что касается userInitSchema
части - "есть много способов проверки данных - лично я рекомендую Zod"
Вы, наверное, уже видите, что здесь что-то не так. Мы не хешируем пароль. Давайте исправим это:
Мы можем изменить оператор return следующим образом:
return new User({ email: userInit.email, password: await bcryptjs.hash(userInit.password) })
Позже, когда мы подтвердим пароль, мы просто вызовем bcryptjs.compare(candidate, this.props.password)
. Но это не тот путь — мы делаем класс User
зависимым от сторонней библиотеки — и ему не нужно заботиться о том, как выполняется хеширование. Вместо этого давайте изолируем пароль от другого объекта, который обрабатывает его для нас.
import { v4 } from "uuid"; export default class User { private constructor( private readonly props: { id: string; email: string; password: HashedPassword; } ) {} static async create(userInit: { email: string; password: string }) { if (!userInitSchema.isValid(userInit)) throw new Error("ERR_INVALID_USER_INIT"); return new User({ id: v4(), email: userInit.email, password: await HashedPassword.create(userInit.password), }); } } class HashedPassword { constructor(private readonly hashValue: string) {} static async create(plaintext: string) { return new HashedPassword(await bcryptjs.hash(plaintext)); } async isEqual(candidate: string): Promise<boolean> { return await bcryptjs.areEqual(this.hashValue, candidate); } }
Намного лучше. Мы могли бы сделать еще один шаг и изолировать bcryptjs
на уровне инфраструктуры, но пока этого достаточно.
Вы можете задаться вопросом, почему мы не позволили User
напрямую зависеть от bcrypt, но позволили ему зависеть от uuid
. Причина в том, что генерация случайных строк почти настолько же универсальна, насколько это возможно. Для этого даже не нужна библиотека. С другой стороны, хеширование заставляет вас использовать одну и ту же библиотеку хеширования в нескольких местах, что делает вас гораздо более уязвимым.
Мы также должны учитывать, что User
нужно хранить в базе данных и позже гидрировать. Для этого давайте создадим еще один статический метод в пользовательском классе:
static async hydrate(userDTO: { email: string; id: string; passwordHash: string; }) { if (!userDTOSchema.isValid(userDTO)) throw new Error("ERR_INVALID_USER_DTO_HYDRATION"); return new User({ email: userDTO.email, password: new HashedPassword(userDTO.passwordHash), id: userDTO.id }); }
Обратите внимание, что аргумент другой — нам требуется id
, а пароль — в хешированном виде.
Я опущу подробности — Khalil Stemmler отлично описал это здесь: https://khalilstemmler.com/articles/typescript-domain-driven-design/repository-dto -картограф/
С этого момента я предполагаю, что у нас есть доступ к глобальному объекту UserRepository.
Теперь напишем клей — функцию прикладного уровня, которая регистрирует пользователя.
Давайте создадим /src/application/UserManager.ts
:
import { User } from "../domain/User"; import { UserRepository } from "../infrastructure/repositories"; import { userToDTO } from "../infrastructure/mappers"; export const UserManager = { async register(userInit: Parameters<typeof User["create"]>[0]) { const user = await User.create(userInit); await UserRepository.save(user); const userDto = userToDTO(user); return { email: userDto.email, id: userDto.id, } } }
Вы могли заметить функцию userToDTO
— она понадобится нам для перевода нашего User
во внешний мир. Давайте посмотрим, как мы можем справиться с этим:
export function userToDTO(user: User) { const props = user["props"]; return { id: props.id, email: props.email, passwordHash: props.password.hashValue } }
Подождите, что? Разве мы не определили user.props
как личное? Тогда почему мы можем получить к ним доступ здесь? Это из-за синтаксиса машинописного текста ["key"]
(обозначение квадратных скобок) - это аварийный люк для таких ситуаций.
Берегитесь возвращать passwordHash
— никогда не возвращайте весь DTO напрямую.
Только теперь мы можем писать код, зависящий от http. Обратите внимание, что раньше мы даже не принимали это во внимание. Напишем максимально простой экспресс-код:
const app = express(); import { UserManager } from "../application/UserManager"; app.post(`/api/user`, async (req, res) => { const result = await UserManager.register(req.body); return res.status(202).json({ success: true, data: { user: result } }); }); app.use((err: any, req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { console.error(err); if(isUserError(err)) { // Determining this is not scope of this article return res.status(400).json({ success: false, error: err }); } else { return res.status(500).end(); } }); app.listen(2000);
Я почти уверен, что теперь вы понимаете смысл названия.
Давайте добавим еще одну функцию с тем же потоком: вход в систему. Нам нужно определить наши потребности в тесте. Было бы здорово иметь метод isPasswordValid
для User
:
import { User } from "../src/domain/User"; describe("User", () => { ... describe("Authentication", () => { const VALID_PASSWORD = "12345678"; const user = await User.create({ email: "[email protected]", password: VALID_PASSWORD }); it("Should indicate password correctness when it's in fact correct, and incorrectness if it's not", async () => { expect(user.isPasswordValid(VALID_PASSWORD)).resolves.toEqual(true); expect(user.isPasswordValid(VALID_PASSWORD+"e")).resolves.toEqual(false); }); }) });
Здорово. Теперь напишем реализацию:
User.ts export default class User { [....] async isPasswordValid(candidatePassword: string): Promise<boolean> { return await this.props.password.isEqual(candidatePassword); } }
Это должно сделать тест успешным. Теперь нам нужно написать вариант использования:
export const UserManager = { ... async login(credentials: { email: string; password: string; }) { const targetUser = await UserRepository.findByEmail(credentials.email); if(!targetUser) { throw new Error("ERR_USER_NOT_FOUND"); } const isPasswordValid = await targetUser.isPasswordValid(credentials.password); if(!isPasswordValid) { throw new Error("ERR_INVALID_CREDENTIALS"); } const { email, id } = userToDTO(targetUser); return targetUser; } }
Выглядит хорошо. Наконец, давайте напишем контроллер:
/src/driver/server.ts [...] app.post(`/api/user/login`, async (req, res) => { const user = await UserManager.login(req.body); //express-session or similar mechanism req.session.user = user; return res.status(200).json({ success: true }); });
Готово! Я думаю, этого достаточно, чтобы вы поняли. Вот некоторые возможности для улучшения того, что мы сделали:
- Мы могли бы создать класс
AppError
, расширяющийError
, чтобы было проще различать операционную ошибку и ошибку программиста. Посмотрите эту замечательную статью - Когда наш
UserManager
вырастет, мы можем захотеть извлечь методlogin
вUserAuthService
или аналогичный - эмпирическое правило заключается в том, что методы должны быть сгруппированы по их назначению. (Пока это название — когда ваше приложение будет расти, вы будете иметь дело с субдоменами и ограниченными контекстами, но это не тема данной статьи) - Вы должны писать тесты для своих вариантов использования. Может быть, даже до написания тестов для объектов доменного уровня. Это зависит от личного вкуса и конкретного случая.