Реализация практического демонстрационного приложения с использованием Typescript

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

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

Просто чтобы было еще понятнее:

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

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

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

Я также видел то, что я называю «системами, ориентированными на базы данных», где модель отражает модель базы данных (и связана с ней), а многие бизнес-правила выражены в командах и запросах SQL, что делает модульное тестирование сложной задачей.

Чистая архитектура

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

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

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

Поэтому я сосредоточился на слоях «Сущности» и «Случаи использования».

Настройка проекта

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

В этом проекте я буду использовать язык программирования Typescript, поэтому для инициализации проекта создадим package.json и tsconfig.json , а также установим некоторые зависимости, используя команды ниже:

npm init
tsc --init 
npm instal express --save
npm install uuid --save
npm install jest --save-dev
// and we can install the related TS types too...

В файле tsconfig.json я изменил выходной каталог на: ./dist и создал несколько скриптов в package.json:

"scripts": {
    "main": "tsc && node ./dist/index.js",
    "test": "tsc && jest ./dist",
    "server": "tsc && node ./dist/src/infra/http/express.js"
  },

Полная версия каждого файла доступна на GitHub: package.json и tsconfig.json.

И теперь мы готовы кодировать!

Давайте кодировать!

Первые каталоги, которые я создаю, это «src» и «test». В папке «src» я создал еще две подпапки «core» и «infra». Папка «core» будет содержать классы с бизнес-логикой, а папка «infra» — конкретные реализации фреймворков и библиотек.

В папке «core» я создал папки «entity», «usecase» и «repository».

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

src/core/entity/Room.ts:

export default class Room {
    
    number: number;
    type: RoomType;
    price: number;
    reservations: Reservation[];
constructor(number: number, type: RoomType, price: number) {
        this.number = number;
        this.type = type;
        this.price = price;
        this.reservations = [];
    }
}
export enum RoomType {
    SINGLE = 'single', 
    DOUBLE = 'double', 
    DELUXE = 'deluxe'
}

src/core/entity/Reservation.ts

export default class Reservation {
id: string | null;
    room: number;
    checkin: Date;
    checkout: Date;
    totalPrice: number;
constructor(room: number, pricePerNight: number, checkin: Date, checkout: Date) {
        this.room = room;
        this.checkin = checkin;
        this.checkout = checkout;
        this.totalPrice = this.nrOfDays * pricePerNight;
        this.id = null;
    }
get nrOfDays() {
        const diffInTime = this.checkout.getTime() - this.checkin.getTime();
        const diffInDays = Math.round(diffInTime / (1000 * 3600 * 24));
        return diffInDays;
    }
    
}

А так как у нас есть некоторая логика в методе получения ‘nrOfDays’ Reservation, я написал модульный тест (используя синтаксис Jest), чтобы убедиться, что он работает:

test/core/entity/Reservation.test.ts:

describe('Reservation entity', () => {
test('Should create reservation from constructor args', async function() {
        const reservation = new Reservation(5, 100, new Date('2022-01-01'), new Date('2022-01-10'));
        expect(reservation.nrOfDays).toBe(9);
        expect(reservation.totalPrice).toBe(100 * 9);
    });
});

И теперь мы можем работать в классах «прецедентов». В этом демонстрационном приложении мы создадим всего два варианта использования: один для поиска свободных номеров, а другой — для бронирования информированного номера.

Класс варианта использования Комната поиска может быть таким, как показано ниже. Обратите внимание, что я использовал шаблон именования методов выполнить XXXX, чтобы обеспечить поиск по датам и операциям по номерам комнат.

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

Этот класс не будет компилироваться прямо сейчас, но он заставляет нас подумать о том, какие методы репозитория нам нужно будет предоставить.

export default class SearchRoom {
private roomRepo: RoomRepository;
constructor(roomRepository: RoomRepository) {
        this.roomRepo = roomRepository;
}
async executebyDates(checkin: Date, checkout: Date): Promise<Room[]> {
        const rooms = await this.roomRepo.getAvailableRooms(checkin, checkout);
        return Promise.resolve(rooms);
    }
async executeByRoomNumber(roomNumber: number): Promise<Room|undefined> {
        const rooms = await this.roomRepo.findAll();
        const room = rooms.find(room => room.number === roomNumber);
        return room;
    }
}

Ниже показан класс прецедента Book Room, в конструкторе которого снова указан интерфейс репозитория:

export default class BookRoom {
private roomRepo: RoomRepository;
constructor(roomRepository: RoomRepository) {
        this.roomRepo = roomRepository;
    }
async execute(room: Room, from: Date, until: Date): Promise<Reservation> {
        const isAvailable = await this.roomRepo.isRoomAvailable(room.number, from, until);
        if (!isAvailable) {
           return Promise.reject('Room not available');
        }
        const reservation = new Reservation(room.number, room.price, from, until);
        const persisted = await this.roomRepo.addReservation(reservation);
        return persisted;
    }
}

Теперь, когда мы знаем, какие методы репозитория нам понадобятся, мы можем создать интерфейс RoomRepository (хранится в папке src/core/repository):

export default interface RoomRepository {
    
findAll(): Promise<Room[]>;
findRoomByNumber(number: number): Promise<Room>;
addReservation(reservation: Reservation) : Promise<Reservation>;
getAvailableRooms(initialDate: Date, endDate: Date) : Promise<Room[]>;
isRoomAvailable(room: number, initialDate: Date, endDate: Date) : Promise<Boolean>;
}

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

Итак, в папке src/infra/repository я реализую класс RoomRepositoryInMemory:

export default class RoomRepositoryInMemory implements RoomRepository {
    
    private roomsData: Room[] = [
        new Room(1, RoomType.SINGLE, 100.0),
        new Room(2, RoomType.SINGLE, 100.0),
        new Room(3, RoomType.SINGLE, 100.0),
        new Room(4, RoomType.DOUBLE, 150.0),
        new Room(5, RoomType.DOUBLE, 150.0),
        new Room(6, RoomType.DOUBLE, 150.0),
        new Room(7, RoomType.DOUBLE, 150.0),
        new Room(8, RoomType.DOUBLE, 150.0),
        new Room(9, RoomType.DELUXE, 200.0),
        new Room(10, RoomType.DELUXE, 200.0)
    ];
    private reservationsData: Reservation[] = [];
findAll(): Promise<Room[]> {
        const rooms = [...this.roomsData];
        return Promise.resolve(rooms);
    }
findRoomByNumber(number: number): Promise<Room> {
        const room = this.loadRoomsWithReservations().find(r => r.number === number);
        return new Promise((resolve,reject) => {
            if (room) resolve(room)
            else reject('Room not found');
        });
    }
    
    addReservation(reservation: Reservation): Promise<Reservation> {
        reservation.id = uuid();
        this.reservationsData.push(reservation);
        return Promise.resolve(reservation);
    }
getAvailableRooms(initialDate: Date, endDate: Date): Promise<Room[]> {
        const rooms = this.loadRoomsWithReservations();
        const availables = rooms.filter(room => this.isAvailable(room.reservations, initialDate, endDate));
        return Promise.resolve(availables);
    }
isRoomAvailable(room: number, initialDate: Date, endDate: Date): Promise<Boolean> {
        const reservations = this.reservationsData.filter(reserv => reserv.room === room);
        const isAvailable = this.isAvailable(reservations, initialDate, endDate);
        return Promise.resolve(isAvailable);
    }
private loadRoomsWithReservations(): Room[] {
        return this.roomsData.map(room => {
            const reservations = this.reservationsData.filter(r => r.room === room.number);
            room.reservations = reservations;
            return room;
        });
    }
private isAvailable(reservations: Reservation[], initialDate: Date, endDate: Date): Boolean {
        const isBooked = reservations.some(r => {
            return (initialDate >= r.checkin && initialDate <= r.checkout) || 
                   (endDate >= r.checkin && endDate <= r.checkout) || 
                   (r.checkin >= initialDate && r.checkin <= endDate) || 
                   (r.checkout >= initialDate && r.checkout <= endDate); 
        });
        return !isBooked;
    }
}

Позже у нас могут быть другие реализации для этого интерфейса репозитория. Например, у нас могут быть реализации «RoomRepositoryMongoDB» и «RoomRepositoryMySQL».

И теперь мы можем реализовать некоторые модульные тесты, чтобы обеспечить правильное поведение классов вариантов использования.

Классы SearchRoom.test.ts и BookRoom.test.ts хранятся в папке test/core/usecase, и там мы можем установить репозиторий в памяти, реализованный выше, как конкретную реализацию репозитория в случае использования. сорт.

describe('Search Room Use Case', () => {
       let roomRepo: RoomRepository;
       let searchRoom: SearchRoom;
beforeEach(() => {
        roomRepo = new RoomRepositoryInMemory();
        searchRoom = new SearchRoom(roomRepo);
    });
// test cases ...

describe('Book Room Use Case', () => {
    let bookRoom: BookRoom;
    let roomRepo: RoomRepository; 
    let room: Room;
beforeEach(() => {
        roomRepo = new RoomRepositoryInMemory();
        bookRoom = new BookRoom(roomRepo);
        room = new Room(5, RoomType.DOUBLE, 150.0);
    });
// test cases ...

Полные тестовые классы доступны здесь: SearchRoom.test.ts и BookRoom.test.ts.

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

Файл express.ts хранился в папке src/infra/http, а файл RoomExpressController.ts — в папке src/infra/controller, так как оба они относятся к инфраструктуре. код.

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

Функции контроллера перечислены ниже:

const roomRepo = new RoomRepositoryInMemory();
const searchRoomService = new SearchRoom(roomRepo);
const bookRoomService = new BookRoom(roomRepo);
export async function searchRoom(req: express.Request, res: express.Response) {
    const params = req.query;
    if (!params || !params.in || !params.out) {
        res.status(400).json( { success: false, message: 'Invalid request' } );
        return;
    }
    const checkin = new Date(params.in as string);
    const checkout = new Date(params.out as string);
    const rooms = await searchRoomService.executebyDates(checkin, checkout)
    res.json( { rooms : [...rooms] } );
}
export async function bookRoom(req: express.Request, res: express.Response) {
    if (!req.body || !req.body.room || !req.body.checkin || !req.body.checkout) {
        res.status(400).json( { success: false, message: 'Invalid request' } );
        return;
    }
    const room = await searchRoomService.executeByRoomNumber(req.body.room);
    if (!room) {
        res.status(404).json( { success: false, message: 'Room not found' } );
        return;
    }
    const checkin = new Date(req.body.checkin);
    const checkout = new Date(req.body.checkout);
    try {
        const reservation = await bookRoomService.execute(room, checkin, checkout);
        res.json( { reservation } );
    } catch(err) {
        if (err instanceof Error) 
            res.json( { success: false, error: err.message })
    }
}

Содержимое express config file показано ниже:

const app = express();
app.use(express.json());
app.get('/api/room/search', searchRoom);
app.post('/api/room/book', bookRoom);
app.listen(5000, () => console.log('Server running'));

Окончательная структура папок будет такой:

Я видел и другие папки в проектах, такие как «адаптеры» для преобразования типов из одного слоя в другой и папку «database-config» для хранения определенных конфигураций.

Мы можем запустить модульный тест с помощью сценария 'npm run test', а также использовать сценарий 'npm run server' для запуска приложения и просмотра ответов на http конечные точки с использованием почтальона:

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

Полный проект доступен на GitHub здесь.

Ресурсы и дополнительная литература:

Подпишитесь на нашу Бесплатную еженедельную рассылку новостей. Свежий контент от разработчиков со всего мира, свежий и прямо с поля! Не забудьте подписаться на нашу публикацию The Dev Project.