Непревзойденная архитектура проекта Node.js

Node.js

оригинал:программное обеспечение на road.com/ideal-node…
Автор: Сэм Куинн
Переводчик: май июнь
Больше качественных статей: обратите внимание на официальный аккаунт»Стек технологий Nodejs", проект с открытым исходным кодом"www.nodejs.red/"

Express.js — отличная платформа для разработки API-интерфейсов Node.js REST, но она не дает никаких подсказок о том, как организовать проект Node.js.

Как бы глупо это не звучало, это реальная проблема.

Правильно организованная структура проекта Node.js позволит избежать дублирования кода и повысить стабильность и масштабируемость сервиса.

Этот пост основан на моих предварительных исследованиях, проведенных в течение многих лет с некоторыми плохими структурами проектов Node.js, плохими шаблонами проектирования и бесчисленными часами рефакторинга кода.

Если вам нужна помощь в структурировании вашего проекта Node.js, просто напишите мне по адресу sam@softwareontheroad.com.

содержание

  • Структура каталогов 🏢
  • Трехуровневая архитектура 🥪
  • сервисный слой 💼
  • Паб/подслой ️️️️🎙️️
  • Внедрение зависимостей 💉
  • Модульное тестирование 🕵🏻
  • Cron Jobs и повторяющиеся задачи ⚡
  • Конфигурация и ключи 🤫
  • Грузчики 🏗️

Структура каталогов 🏢

Я говорю о структуре проекта Node.js.

Я использую следующую структуру в каждой службе Node.js REST API, которую я создаю, давайте разберемся, что делает каждый компонент.

src
  │   app.js          # App 入口
  └───api             # Express route controllers for all the endpoints of the app
  └───config          # 环境变量和配置相关
  └───jobs            # 对于 agenda.js 的任务调度定义
  └───loaders         # 将启动过程拆分为模块
  └───models          # 数据库模型
  └───services        # 所有的业务逻辑应该在这里
  └───subscribers     # 异步任务的事件处理程序
  └───types           # 对于 Typescript 的类型声明文件(d.ts)

Это не просто способ организации файлов JavaScript...

Трехуровневая архитектура 🥪

Идея состоит в том, чтобы использоватьРазделение интересовВынесите бизнес-логику из маршрутизации API Node.js.

图片描述

Потому что однажды вы захотите использовать свою бизнес-логику в инструменте CLI или никогда. Очевидно, что это не очень хорошая идея для некоторых повторяющихся задач, а затем вызывать себя с сервера Node.js.

图片描述

☠️ Не помещайте свою бизнес-логику в контроллеры!! ☠️

Вы можете захотеть использовать уровень контроллеров Express.js для хранения бизнес-логики уровня приложения, но вскоре ваш код станет неподдерживаемым, и пока вам нужно писать модульные тесты, вам нужно писать сложность Express. js req или res моделирование объектов.

Определить, когда должен быть отправлен ответ и когда обработка должна продолжаться «в фоновом режиме» (например, после того, как ответ был отправлен клиенту), является более сложным.

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

    // 这应该是一个中间件或者应该由像 Joi 这样的库来处理
    // Joi 是一个数据校验的库 github.com/hapijs/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, это просто набор классов с четкой целью.

На этом уровне не должно быть никаких «запросов SQL», можно использовать уровень доступа к данным.

  • Удалите свой код с маршрутизатора Express.js.
  • Не передавайте req или res на сервисный уровень
  • Не возвращайте какую-либо информацию, относящуюся к транспортному уровню HTTP, из сервисного уровня, такую ​​как код состояния или заголовки.

пример

route.post('/', 
    validators.userSignup, // 这个中间层负责数据校验
    async (req, res, next) => {
      // 路由层实际负责的
      const userDTO = req.body;

      // 调用 Service 层
      // 关于如何访问数据层和业务逻辑层的抽象
      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); // needs userRecord to have the database id 
        const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

        ...whatever

        await EmailService.startSignupSequence(userRecord)

        ...do more stuff

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

Опубликовать и подписаться на слой 🎙️

Шаблон pub/sub выходит за рамки представленной здесь классической трехуровневой архитектуры, но он очень полезен.

Теперь создайте простую конечную точку API Node.js для пользователя, возможно, вызывая стороннюю службу, возможно, службу аналитики, возможно, запуская последовательность электронной почты.

Вскоре эта простая операция «создать» будет делать несколько вещей, и в итоге вы получите 1000 строк кода в одной функции.

Это нарушает принцип единой ответственности.

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

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 };
    }

  }

Принудительный вызов зависимой службы не является хорошей практикой.

Один из лучших способов сделать это — запустить событие «user_signup», которое делается следующим образом, а все остальное зависит от прослушивателя событий.

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)
})

Вы можете обернуть оператор ожидания в блок try-catch или позволить ему завершиться ошибкой и обработать process.on('unhandledRejection',cb) через 'unhandledPromise'.

Внедрение зависимостей 💉

DI или инверсия управления (IoC) — это распространенный шаблон, который помогает в организации кода путем «внедрения» или передачи зависимостей класса или функции через конструктор.

Таким образом, у вас есть возможность вводить «совместимые зависимости», например, когда вы пишете модульные тесты для службы или при использовании службы в других контекстах.

Код без цифрового входа

import UserModel from '../models/user';
import CompanyModel from '../models/company';
import SalaryModel from '../models/salary';  
class UserService {
    constructor(){}
    Sigup(){
        // Caling UserMode, CompanyModel, etc
        ...
    }
}

Код с ручным внедрением зависимостей

export default class UserService {
    constructor(userModel, companyModel, salaryModel){
        this.userModel = userModel;
        this.companyModel = companyModel;
        this.salaryModel = salaryModel;
    }
    getMyUser(userId){
        // models available throug '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');

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

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

Теперь давайте рассмотрим пример библиотеки NPM с использованием TypeDI. В следующем примере Node.js будет представлен DI.

Более подробную информацию о TypeDI можно найти на официальном сайте.

Woohoo.GitHub.com/type stack/he…

пример машинописного текста

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;
    }
}

services/user.ts

Теперь TypeDI позаботится о разрешении любых зависимостей, которые нужны UserService.

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

Злоупотребление вызовами локатора сервисов — это антишаблон

Внедрение зависимостей на практике с Express.js

Использование DI в 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 });
    });

Отлично, проект выглядит великолепно! Он настолько организован, что мне хочется писать код прямо сейчас.

Пример модульного теста 🕵🏻

Используя внедрение зависимостей и эти организационные шаблоны, модульное тестирование становится очень простым.

Вам не нужно имитировать объекты 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();
      });
    })
  })

Cron Jobs и повторяющиеся задачи ⚡

Итак, теперь, когда бизнес-логика инкапсулирована в сервисный уровень, ее проще использовать из задания Cron.

Вы не должны полагаться на Node.js setTimeout или другие примитивные способы задержки выполнения кода, а должны полагаться на структуру, которая сохраняет ваши задания и их выполнение в базе данных.

Таким образом, вы будете контролировать невыполненные задания и получать отзывы от победителей. См. мою статью о лучших диспетчерах задач Node.js.софт на road.com/node будет - потерся...

Конфигурация и ключи 🤫

Следуйте протестированному приложению с двенадцатью факторами для Node.js (приложение с двенадцатью факторами12factor.net/), которая является лучшей практикой для хранения ключей API и строк ссылок базы данных с использованием dotenv.

Поместите файл .env, который никогда не может быть зафиксирован (но он должен существовать в репозитории со значениями по умолчанию), затем этот пакет dotenv NPM загрузит файл .env и запишет переменные внутри в объект Node.js process.env.

Этого достаточно, однако я хотел бы добавить еще один шаг. Существует файл 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, а благодаря автодополнению вам не нужно знать, как называть переменные среды.

Грузчики 🏗️

Я беру этот шаблон из микрофреймворка 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 !`);
    });
  }

  // Run the async function to start our server
  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');

    // ... more loaders can be here

    // ... Initialize agenda
    // ... or Redis, or whatever you want
  }

The express loader

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 }));

    // ...More middlewares

    // Return the express app
    return app;
})

The mongo loader

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;
}

Приведенный выше код можно загрузить из репозитория кода.GitHub.com/за три вперед/не более…Получать.

в заключении

Мы глубоко погрузились в структуру проекта Node.js, протестированного в производственной среде, и вот несколько советов, которые можно подытожить:

  • Используйте трехуровневую архитектуру.
  • Не размещайте свою бизнес-логику в контроллерах Express.js.
  • Используйте шаблон Pub/Sub и запускайте события для фоновых задач.
  • Сделайте внедрение зависимостей для душевного спокойствия.
  • Никогда не раскрывайте свои пароли, секреты и ключи API, используйте менеджер конфигурации.
  • Разделите конфигурацию сервера Node.js на небольшие модули, которые можно загружать независимо.