Надійна архітектура проєкту на Node.js

21 хв. читання

Інтро

Розробку на Node.js зараз спрощують фреймворки на зразок Express, однак вони не відповідають за організацію самого проєкту. Вибір та організація правильної архітектури проєкту — чи не найскладніший етап розробки.

З правильною організацією структури проєкту ви уникнете дублювання коду, отримаєте стабільність та в перспективі спростите масштабування системи.

Цей матеріал — результат аналізу багатьох невдалих структур проєктів на Node.js. Якщо візьмете до уваги описані антипатерни, проводитимете мінімум часу за рефакторингом коду.

Структура тек

Розглянемо одну зі структур проєкту на Node.js та призначення кожного з компонентів:

  src
  │   app.js          # вхідна точка застосунку
  └───api             # контролери роутів Express для всіх ендпоінтів застосунку 
  └───config          # змінні середовища та все, що стосується конфігів 
  └───jobs            # планувальники завдань
  └───loaders         # розділення процесу запуску на модулі 
  └───models          # моделі БД 
  └───services        # вся бізнес-логіка 
  └───subscribers     # обробники подій для асинхронних задач 
  └───types           # визначення типів (d.ts) для Typescript

Йдеться про щось більше, ніж просто про впорядкування JavaScript-файлів...

3-рівнева архітектура

Суть в тому, щоб використовувати принцип розділення обов'язків, аби винести бізнес-логіку за межі роутів node.js API.

Надійна архітектура проєкту на Node.js

Чому саме таке рішення? Усе тому, що якось ви захочете використати вашу бізнес-логіку в CLI-інструменті або навіть в схожому завданні.

До того ж робити API-виклик з сервера node.js до себе самого ж — також не найкраща практика.

Надійна архітектура проєкту на Node.js

Не розміщуйте вашу бізнес-логіку всередині контролерів ☠️

Ідея використовувати контролери Express.js для зберігання бізнес-логіки може здатись привабливою. Проте так ви швидко отримаєте «спагеті-код», який складно буде тестувати юніт-тестами, адже доведеться мокати кожен об'єкт req та res.

Буває досить складно визначити, коли слід надсилати відповідь, а коли продовжувати обробку у фоновому режимі, скажімо, після надсилання відповіді клієнту.

Ось приклад, як робити НЕ треба:

 route.post('/', async (req, res, next) => {

    // Тут повинен бути middleware або обробка такими бібліотеками, як Joi
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // Багато бізнес-логіки тут...
    const userRecord = await UserModel.create(userDTO);
    delete userRecord.password;
    delete userRecord.salt;
    const companyRecord = await CompanyModel.create(userRecord);
    const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

    ...whatever...


    // А це «оптимізація», яка все псує 
    // Відповідь надсилається клієнту...
    res.json({ user: userRecord, company: companyRecord });

    // Але виконання коду продовжується :(
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
    eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
    intercom.createUser(userRecord);
    gaAnalytics.event('user_signup',userRecord);
    await EmailService.startSignupSequence(userRecord)
  });

Використовуємо сервісний шар для вашої бізнес-логіки 💼

Що таке сервісний шар? Перш за все, це колекція класів з чітким призначенням, реалізованих відповідно до SOLID-принципів, застосовних до Node.js.

Сервісний шар не повинен містити жодної форми звернення до БД — за це відповідальний шар доступу до даних.

  • Приберіть зайвий код з роутера Express.js;
  • Не передавайте req- або res-об'єкти в сервісний шар;
  • Не повертайте будь-що, пов'язане з HTTP-шаром, на зразок статусних кодів чи заголовків з сервісного рівня.

Приклад:

  route.post('/', 
    validators.userSignup, // middleware турбується про валідацію
    async (req, res, next) => {
      // Справжня відповідальність шару роутера
      const userDTO = req.body;

      // Виклик сервісного шару
      // Абстрагуємось від шару доступу до даних та сервісного шару з бізнес-логікою 
      const { user, company } = await UserService.Signup(userDTO);

      // Повертаємо відповідь клієнту
      return res.json({ user, company });
    });

Розглянемо логіку створеного сервісу:

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';

  export default class UserService {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(userRecord); // userRecord повинен мати id 
      const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // залежить від користувача та компанії, яка створюється 

      ...whatever

      await EmailService.startSignupSequence(userRecord)

      ...do more stuff

      return { user: userRecord, company: companyRecord };
    }
  }

Повний сирцевий код прикладу.

Використовуйте шар Pub/Sub

Патерн Pub/Sub виходить за межі класичної 3-рівневої архітектури, запропонованої вище, однак його користь не варто недооцінювати.

Простий node.js API-ендпоінт, який створює зараз користувача, може звертатися до стороннього сервісу (можливо, аналітичного сервісу) або починати послідовність надсилання імейлу.

Рано чи пізно проста операція «create» починає робити дії, не зовсім пов'язані з операцією створення: функція буде перевантажена зайвим кодом.

Тут ми явно порушуємо принцип єдиної відповідальності.

Набагато краще з самого початку розділити обов'язки — і ваш код залишиться підтримуваним:

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(user);
      const salaryRecord = await SalaryModel.create(user, salary);

      eventTracker.track(
        'user_signup',
        userRecord,
        companyRecord,
        salaryRecord
      );

      intercom.createUser(
        userRecord
      );

      gaAnalytics.event(
        'user_signup',
        userRecord
      );

      await EmailService.startSignupSequence(userRecord)

      ...more stuff

      return { user: userRecord, company: companyRecord };
    }

  }

Імперативно викликати залежний сервіс — не найкращий спосіб розв'язання проблеми.

Кращим підходом буде замінити подію на зразок «користувач зареєструвався з певним імейлом».

На цьому все. Далі справа за слухачами події.

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await this.userModel.create(user);
      const companyRecord = await this.companyModel.create(user);
      this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
      return userRecord
    }
  }

Тепер ви можете розділити слухачів подій та обробників по окремих файлах.

  eventEmitter.on('user_signup', ({ user, company }) => {

    eventTracker.track(
      'user_signup',
      user,
      company,
    );

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })
  eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })
  eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })

Ви можете огорнути await-вирази у блок try-catch, або ж ви можете залишити як є і обробити unhandledPromise.

process.on('unhandledRejection',cb)

Dependency Injection 💉

D.I. або інверсія контролю (IoC) — загальний патерн, який допомагає організувати код, «вводячи» або передаючи через конструктор залежності вашого класу чи функції.

Так ви отримуєте гнучкість для введення «сумісної залежності», коли, наприклад, ви пишете юніт-тести для сервісу або коли сервіс використовується в іншому контексті.

Код без Dependency Injection:

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
      // Виклик UserMode, CompanyModel тощо
      ...
    }
  }

Код з реалізацією Dependency Injection без сторонніх бібліотек:

  export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // моделі доступні через 'this'
      const user = this.userModel.findById(userId);
      return user;
    }
  }

Тепер ви можете ввести кастомні залежності:

  import UserService from '../services/user';
  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  const salaryModelMock = {
    calculateNetSalary(){
      return 42;
    }
  }
  const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
  const user = await userServiceInstance.getMyUser('12346');

Сервіс підтримує необмежену кількість залежностей, тому рефакторинг може видатись досить непростим завданням. Тут на допомогу приходять фреймворки dependency injection.

Суть в тому, що ви оголошуєте ваші залежності в класі, а коли вам потрібен екземпляр класу, просто викликаєте «Локатор сервісу».

Розглянемо, як реалізувати D.I в Node.js за допомогою npm-бібліотеки typedi.

Більше про використання typedi в офіційній документації.

Зверніть увагу, тут використовується TypeScript: services/user.ts

  import { Service } from 'typedi';
  @Service()
  export default class UserService {
    constructor(
      private userModel,
      private companyModel, 
      private salaryModel
    ){}

    getMyUser(userId){
      const user = this.userModel.findById(userId);
      return user;
    }
  }

Тепер настала черга typedi зарезолвити залежності, які пропонує UserService.

 import { Container } from 'typedi';
  import UserService from '../services/user';
  const userServiceInstance = Container.get(UserService);
  const user = await userServiceInstance.getMyUser('12346');

Зловживання викликами локатора сервісів вважається антипатерном.

Використання Dependency Injection з Express.js у Node.js

Використання D.I. в Express.js — кінцевий етап організації архітектури проєкту на Node.js

Шар роутингу

  route.post('/', 
    async (req, res, next) => {
      const userDTO = req.body;

      const userServiceInstance = Container.get(UserService) // Service locator

      const { user, company } = userServiceInstance.Signup(userDTO);

      return res.json({ user, company });
    });

Блискуче! Тепер наш проєкт має чудовий вигляд. Репозиторій з сирцевим кодом..

Юніт-тестування проєкту

З реалізованим dependency injection та відповідною структурою проєкту, юніт-тестування значно полегшується.

Тепер вам не потрібно мокати об'єкти req/res або виклики require().

Приклад: юніт-тест методу реєстрації користувача

tests/unit/services/user.js

 import UserService from '../../../src/services/user';

  describe('User service unit tests', () => {
    describe('Signup', () => {
      test('Should create user record and emit user_signup event', async () => {
        const eventEmitterService = {
          emit: jest.fn(),
        };

        const userModel = {
          create: (user) => {
            return {
              ...user,
              _id: 'mock-user-id'
            }
          },
        };

        const companyModel = {
          create: (user) => {
            return {
              owner: user._id,
              companyTaxId: '12345',
            }
          },
        };

        const userInput= {
          fullname: 'User Unit Test',
          email: 'test@example.com',
        };

        const userService = new UserService(userModel, companyModel, eventEmitterService);
        const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

        expect(userRecord).toBeDefined();
        expect(userRecord._id).toBeDefined();
        expect(eventEmitterService.emit).toBeCalled();
      });
    })
  })

Планувальники задач та повторювані завдання ⚡

Тепер наша бізнес-логіка інкапсульована в сервісний шар, тому нам набагато легше використовувати її в планувальнику задач.

Не слід покладатися на setTimeout в node.js або на інший примітивний спосіб відкласти виконання коду. Такі задачі повинні керуватися фреймворком.

Так ви зможете контролювати невиконані завдання, а також отримувати фідбек від вдалих. Детальніше про використання таск-менеджерів, зокрема agenda.js у node.js, за посиланням.

Конфігурація

Відповідно до перевірених концепцій Застосунку 12 факторів у Node.js, найкращий спосіб зберігати API-ключі та налаштування підключень до БД — використовувати dotenv.

Створіть .env-файл, який ніколи не повинен комітитись (проте варто подбати про такий файл зі значеннями за замовчуванням). Далі npm-пакет dotenv завантажує створений файл та розміщує визначені змінні в об'єкті process.env Node.js.

Тут можна зупинитись, проте ми розглянемо ще один додатковий крок.

Створюємо файл config/index.ts, де під'єднуємо npm-пакет dotenv, підвантажуємо .env-файл та використовуємо об'єкт для збереження змінних. Так ми отримаємо структурований код та автодоповнення.

config/index.js

  const dotenv = require('dotenv');
  // config() зчитає ваш .env-файл, розпарсить контент, та додасть до process.env
  dotenv.config();

  export default {
    port: process.env.PORT,
    databaseURL: process.env.DATABASE_URI,
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    mailchimp: {
      apiKey: process.env.MAILCHIMP_API_KEY,
      sender: process.env.MAILCHIMP_SENDER,
    }
  }

З таким підходом ми не забруднюємо код змінними на зразок process.env.MY_RANDOM_VAR, до того ж автодоповнення значно пришвидшить та спростить вашу роботу.

Репозиторій з сирцевим кодом з прикладу

Завантажувачі (Loaders)

Розглянемо патерн з мікрофреймворка W3Tech, проте не будемо покладатися на їхній пакет.

Суть в тому, що ми розбиваємо процес запуску node.js-сервісу на тестовані модулі.

Розглянемо класичний приклад ініціалізації застосунку на Express.js.

 const mongoose = require('mongoose');
  const express = require('express');
  const bodyParser = require('body-parser');
  const session = require('express-session');
  const cors = require('cors');
  const errorhandler = require('errorhandler');
  const app = express();

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));
  app.use(bodyParser.json(setupForStripeWebhooks));
  app.use(require('method-override')());
  app.use(express.static(__dirname + '/public'));
  app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
  mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

  require('./config/passport');
  require('./models/user');
  require('./models/company');
  app.use(require('./routes'));
  app.use((req, res, next) => {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
  });
  app.use((err, req, res) => {
    res.status(err.status || 500);
    res.json({'errors': {
      message: err.message,
      error: {}
    }});
  });


  ... more stuff 

  ... maybe start up Redis

  ... maybe add more middlewares

  async function startServer() {    
    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  // Викличемо асинхронну функцію, щоб запустити наш сервер 
  startServer();

Як можна помітити, наш код не дуже впорядкований.

Розглянемо більш ефективний спосіб:

  const loaders = require('./loaders');
  const express = require('express');

  async function startServer() {

    const app = express();

    await loaders.init({ expressApp: app });

    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  startServer();

Тепер лоадери розподілені в невеликі файли з чіткою метою.

loaders/index.js

  import expressLoader from './express';
  import mongooseLoader from './mongoose';

  export default async ({ expressApp }) => {
    const mongoConnection = await mongooseLoader();
    console.log('MongoDB Intialized');
    await expressLoader({ app: expressApp });
    console.log('Express Intialized');

    // ... тут може бути більше лоадерів 

    // ... ініціалізація agenda
    // ... або Redis чи будь-що інше
  }

loaders/express.js

  import * as express from 'express';
  import * as bodyParser from 'body-parser';
  import * as cors from 'cors';

  export default async ({ app }: { app: express.Application }) => {

    app.get('/status', (req, res) => { res.status(200).end(); });
    app.head('/status', (req, res) => { res.status(200).end(); });
    app.enable('trust proxy');

    app.use(cors());
    app.use(require('morgan')('dev'));
    app.use(bodyParser.urlencoded({ extended: false }));

    // ...Більше middlewares

    // Повертаємо express app
    return app;
  })

loaders/mongoose.js

  import * as mongoose from 'mongoose'
  export default async (): Promise<any> => {
    const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
    return connection.connection.db;
  }

Репозиторій з сирцевим кодом.

Висновок

В матеріалі ми розглянули приклад створення тестованої архітектури проєкту на node.js . Підсумуємо розглянуті концепції:

  • використовуйте 3-рівневу архітектуру;
  • не розміщуйте вашу бізнес-логіку всередині контролерів Express.js;
  • послуговуйтесь патерном Pub/Sub та емітьте події для фонових завдань;
  • використовуйте dependency injection для власного ж спокою;
  • ніколи не розміщуйте паролі, секрети, ключі API у відкритому доступі — згадайте про менеджер конфігурацій;
  • розбивайте конфігурацію вашого node.js-сервера у невеликі модулі, які можна завантажити незалежно.

Репозиторій з прикладом.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація