Vzor minimalistické architektury pro aplikace Express.js API

Express.js je minimální rámec webových aplikací, který zvyšuje produktivitu webových vývojářů. Je velmi flexibilní a nevynucuje žádný vzor architektury. Tento článek demonstruje nový vzor architektury, který jsem navrhl a který dále zlepší vaši produktivitu.

Jak číst tento článek

Tento článek představuje vzor, ​​který se liší od oblíbeného vzoru MVC nebo MSC (Model-Service-Controller). Než se o některém z nich dozvíte, můžete si přečíst tento článek.

Ukázkový projekt GitHub

Pojďme vytvořit restaurační aplikaci RESTful API.

Pravidla přístupu

  • Veřejní uživatelé:
    • vytvořit účet
    • přihlaste se
  • Členové:
    • přečíst si všechny dostupné restaurace v okolí
  • Vlastníci:
    • CRUD všechny restaurace v okolí
  • Správci:
    • CRUD všechny restaurace v okolí
    • CRUD všechny uživatele

Požadavky

  • Každý objekt restaurace musí mít název, zeměpisné souřadnice, dostupný stav a ID vlastníka.
  • Každý uživatelský objekt musí mít jméno, e-mail, typ uživatele (člen/vlastník/admin) a heslo.

Technický zásobník v této ukázce

  • Databáze:MongoDB
  • ORM:Mongoose

Konvence odezvy JSON

Když posíláme data JSON zpět klientovi, můžeme mít konvence, které identifikují úspěšnou nebo neúspěšnou operaci, například

{
  success: false,
  error: ...
}
{
  success: true,
  data: ...
}

Pojďme vytvořit funkce pro odpovědi JSON výše.

./common/response.js

function errorRes (res, err, errMsg="failed operation", statusCode=500) {
  console.error("ERROR:", err)
  return res.status(statusCode).json({ success: false, error: errMsg })
}

function successRes (res, data, statusCode=200) {
  return res.status(statusCode).json({ success: true, data })
}

Zde používáme výchozí argumenty pro obě funkce, výhodou je, že funkci můžeme použít jako:

errorRes(res, err)
successRes(res, data)

a nemusíme kontrolovat, zda jsou volitelné argumenty null.

// Example when default arguments not in use.
function errorRes (res, err, errMsg, statusCode) {
  if (errMsg) {
    if (statusCode) {
      ...
    }
    ...
  }
}

// or using ternary operator
function successRes (res, data, statusCode) {
  const resStatusCode = statusCode ? statusCode : 200
  ...
}

Neváhejte a nahraďte console.error s funkcí protokolování (z jiné knihovny), kterou preferujete.

Konvence asynchronního zpětného volání databáze

Pro operace vytváření, čtení, aktualizace a odstraňování má většina databázových ORM/ovladačů konvenci zpětného volání jako:

(err, data) => ...

když to víme, přidáme do ./common/response.js další funkci

./common/response.js

function errData (res, errMsg="failed operation") {
  return (err, data) => {
    if (err) return errorRes(res, err, errMsg)
    return successRes(res, data)
  }
}

Exportujte všechny funkce do ./common/response.js

module.exports = { errorRes, successRes, errData }

Konvence databázových operací (CRUD)

Pojďme definovat funkce databázových operací pro všechny modely. Zde se používají konvence req.body jako zdroj dat a req.params._id jako ID objektu sbírky. Většina funkcí bude mít jako argumenty model a seznam vyplňujících polí, kromě operace mazání (není nutné vyplňovat smazaný záznam). Od delete je vyhrazené klíčové slovo v JavaScriptu (pro odstranění vlastnosti z objektu), používáme remove jako název funkce operace odstranění, aby nedošlo ke konfliktu.

./common/crud.js

const { errData, errorRes, successRes } = require('../common/response')
const mongoose = require('mongoose')


function create (model, populate=[]) {
  return (req, res) => {
    const newData = new model({
      _id: new mongoose.Types.ObjectId(),
      ...req.body
    })
    return newData.save()
      .then(t => t.populate(...populate, errData(res)))
      .catch(err => errorRes(res, err))
  }
}

function read (model, populate=[]) {
  return (req, res) => (
    model.find(...req.body, errData(res)).populate(...populate)
  )
}

function update (model, populate=[]) {
  return (req, res) => {
    req.body.updated_at = new Date()
    return model.findByIdAndUpdate(
            req.params._id,
            req.body,
            { new: true },
            errData(res)
          ).populate(...populate)
  }
}

function remove (model) {
  return (req, res) => (
    model.deleteOne({ _id: req.params._id }, errData(res))
  )
}

module.exports = { read, create, update, remove }

Funkce databáze CRUD výše používala funkce z ./common/response .

Připraveno k vývoji

Se všemi výše definovanými funkcemi jsme připraveni na vývoj aplikací. Nyní potřebujeme pouze definovat datové modely a směrovače.
Pojďme definovat datové modely v ./models

./models/Restaurant.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const ObjectId = Schema.Types.ObjectId
const validator = require('validator')


const restaurantSchema = new Schema({
  _id: ObjectId,
  name: { type: String, required: true },
  location: {
    type: {
      type: String,
      enum: [ 'Point' ],
      required: true
    },
    coordinates: {
      type: [ Number ],
      required: true
    }
  },
  owner: { type: ObjectId, ref: 'User', required: true },
  available: {
    type: Boolean,
    required: true,
  },

  updated_at: Date,
});

module.exports = mongoose.model('Restaurant', restaurantSchema, 'restaurants');

./models/User.js

const mongoose = require('mongoose')
const Schema = mongoose.Schema
const ObjectId = Schema.ObjectId
const validator = require('validator')


const userSchema = new Schema({
  _id: ObjectId,
  name: { type: String, required: true },
  email: {
    type: String,
    required: true,
    unique: true,
    validate: [ validator.isEmail, 'invalid email' ]
  },
  type: {
    type: String,
    enum: ['member', 'owner', 'admin'],
    required: true
  },
  password: { type: String, required: true, select: false },

  updated_at: Date,
});

module.exports = mongoose.model('User', userSchema, 'users');

Výše uvedené modely jsou velmi běžné, není v nich nic nového ani nápaditého.

Směrování a manipulátory

Z výše uvedené konvence databáze si můžete myslet, že použití req.body jako zdroje dat je velmi omezené, pokud potřebujete pole JSON backendového procesu. Zde můžeme použít middleware k vyřešení omezení.

./api/user.js

router
.use(onlyAdmin)
.post('/', create(User))
.get('/all/:page', usersAtPage, read(User))
.put('/:_id', handlePassword, update(User))
.delete('/:_id', remove(User))

./api/restaurant.js

const express = require('express')
const router = express.Router()
const { create, read, update, remove } = require('../common/crud')
const Restaurant = require('../models/Restaurant')

router
.get('/all/:lng/:lat/:page', nearBy(), read(Restaurant, ['owner']))
.get('/available/:lng/:lat/:page',
  nearBy({ available: true }),
  read(Restaurant, ['owner'])
)

function nearBy (query={}) {
  return (req, res, next) => {
    const { lng, lat, page } = req.params
    req.body = geoQuery(lng, lat, query, page)
    next()
  }
}

./api/auth.js

router
.post('/signup', isValidPassword, hashPassword, signUp)
.post('/login', isValidPassword, findByEmail, verifyPassword, login)

// middlewares below are used for processing `password` field in `req.body`
function isValidPassword (req, res, next) {
  const { password } = req.body
  if (!password || password.length < 6) {
    const err = `invalid password: ${password}`
    const errMsg = 'password is too short'
    return errorRes(res, err, errMsg)
  }
  return next()
}

function hashPassword (req, res, next) {
  const { password } = req.body
  bcrypt.hash(password, saltRounds, (err, hashed) => {
    if (err)
      return errorRes(res, err, 'unable to sign up, try again')
    req.body.password = hashed
    return next()
  })
}

function signUp (req, res) {
...
}

function findByEmail (req, res, next) {
....
}

function verifyPassword (req, res, next) {
  ...
}

function login (req, res) {
  ...
}

module.exports = router;

Jak prodloužit

Rozšíření aplikace vyžaduje pouze přidání nových modelů a definování nových směrovačů pro koncové body.

Rozdíly od MSC

Vzor Model-Service-Controller vyžaduje, aby každý databázový model měl sadu servisních funkcí pro datové operace. A tyto servisní funkce jsou specificky definovány pouze pro konkrétní model. S novou architekturou výše přeskočíme definici servisních funkcí pro každý model opětovným použitím běžných funkcí databázových operací, čímž zvýšíme naši produktivitu.

Souhrn

Tato architektura poskytuje velkou flexibilitu pro přizpůsobení, například nevynucuje jinou strukturu složek než s common můžete do souborů routeru vkládat všechny funkce middlewaru nebo je oddělovat podle vašich pravidel. Použitím a rozšířením funkcí v common můžete buď začít projekt od začátku, nebo produktivně refaktorovat/pokračovat ve velkém projektu. Doposud jsem tuto architekturu používal pro libovolnou velikost projektů ExpressJS.

sharedbynil / ko-architecture

Vzor minimalistické architektury pro aplikace ExpressJS API

Ukázka architektury K.O

  • Rámec:ExpressJS
  • Databáze:MongoDB
  • Ověření:Webový token JSON

Data experimentu

  • původ:restaurace.json

Dokument rozhraní API

Kolekci a prostředí Postman API lze importovat z ./postman/

Před spuštěním

Aktualizujte ./config.js soubor

module.exports = {
  saltRounds: 10,
  jwtSecretSalt: '87908798',
  devMongoUrl: 'mongodb://localhost/kane',
  prodMongoUrl: 'mongodb://localhost/kane',
  testMongoUrl: 'mongodb://localhost/test',
}

Importujte data experimentu

Otevřete terminál a spusťte:

mongod

Otevřete další terminál v tomto adresáři:

bash ./data/import.sh

Spusťte server pomocí

npm start

Začněte s vývojem

npm run dev
Zobrazit na GitHubu