Если вы читаете это, вы, вероятно, хотя бы раз писали серверный код. Вам когда-нибудь было трудно добавить небольшую функцию в систему? Требуется ли все больше и больше времени для внедрения новых функций или исправления ошибок? Возможно, вы неправильно заложили основу. Позвольте мне показать вам проверенный в бою способ создания серверного кода, который упрощает модификацию и расширение вашей системы в дальнейшем. Вам может быть интересно, какое отношение это имеет к названию — смысл в том, чтобы переключить внимание с веб-фреймворков на моделирование реальности бизнеса, для которого вы пишете код. Я собираюсь показать вам сочетание того, что я извлек и нашел действительно полезным из некоторых методологий, в первую очередь 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 или аналогичный - эмпирическое правило заключается в том, что методы должны быть сгруппированы по их назначению. (Пока это название — когда ваше приложение будет расти, вы будете иметь дело с субдоменами и ограниченными контекстами, но это не тема данной статьи)
  • Вы должны писать тесты для своих вариантов использования. Может быть, даже до написания тестов для объектов доменного уровня. Это зависит от личного вкуса и конкретного случая.