Antipattern ExpressJS:vytváření middlewaru ze všeho

Něco, co vidím v mnoha rozhraních ExpressJS API, je nadměrné / nesprávné používání middlewaru. Někdy do té míry, že téměř vše je middleware.

Obvykle to nakonec vypadá takto:

const router = express.Router()

const getCustomerData = async (req, res, next) => {
  try {
    const customerId = req.body.customerId
    
    const customerDetails = await fetchUserDetails(customerId)

    res.locals.transactionHistory = await fetchCustomerTransactionHistory(customerDetails)

    next()

    return
  } catch (err) {
    next(error)

    return
  }
}

const processOrder = async (req, res, next) => {
  try {
    const customerDiscount = await calculateDiscountFromCustomerTransactionHistory(res.locals.transactionHistory)

    let recalculatedOrderTotal

    if (customerDiscount) {
      recalculatedOrderTotal = req.body.orderTotal - (req.body.orderTotal * customerDiscount)
    }

    const itemsAreInStock = await doubleCheckStock(req.body.orderItems)

    if (!itemsAreInStock) {
      return res.send('Item(s) out of stock')
    }

    await insertOrder(recalculatedOrderTotal)
    await chargeCustomerPayment(recalculatedOrderTotal || orderTotal, req.body.paymentDetails)

    next()

    return
  } catch (err) {
    next(error)

    return
  }
}

const sendConfirmationEmailToCustomer = async (req, res, next) => {
  try {
    await dispatchConfirmationEmailJob(req.body.customerId, req.body.orderItems)

    res.send('Order complete')

    return
  } catch (err) {
    return
  }
}

router.post('/order', getCustomerData, processOrder, sendConfirmationEmailToCustomer)

„Middleware“ je zde vše, co závisí na ExpressJS req /res /next kontext. Můžete vidět, že jsou také zřetězeny tam, kde je definována trasa:

router.post('/order', getCustomerData, processOrder, sendConfirmationEmailToCustomer)

Poznámka:Váš kontrolér bude také obvykle záviset na kontextu Express, ale nebude se chovat jako middleware v tom, že je zřetězen od jednoho volání k dalšímu v definici trasy. Kontrolér bude mít obvykle jeden vstupní bod – takže jedna funkce kontroléru na trasu . Toto není pevně stanovené pravidlo, ale obecně se jedná o osvědčený postup.

Middleware v ExpressJS obvykle vidíte pomocí app.use(someMiddleware) registrovat řetězec middlewaru v pořadí. A i když to není příklad toho, tvrdil bych, že je stále kódován v podstatě jako middleware kvůli silné závislosti na kontextu ExpressJS. Jen je v kódu na jiném místě - v definici trasy místo index.js nebo app.js část kódu, kde vidíte app.use(someMiddleware) nastavení.

Co dělá tento kód? Pár věcí:

  • getCustomerData()
    • načte podrobnosti o uživateli (pravděpodobně z databáze)
    • načte historii transakcí zákazníka (pravděpodobně také z databáze)
  • processOrder()
    • vypočítá případnou slevu pro uživatele
    • zkontroluje, zda jsou položky na skladě
    • vloží objednávku položky do databáze
    • účtuje zákazníkovi kreditní kartu nebo jiný způsob platby
  • sendConfirmationEmailToCustomer()
    • zašlete uživateli potvrzovací e-mail s podrobnostmi objednávky

Co to dělá problém?

Problém ve skutečnosti není v tom, co kód dělá, ale jak, a to z následujících důvodů:

  • Tyto tři funkce nyní závisí na kontextu požadavku. Pokud je chcete znovu použít / použít na více místech, každá funkce, která toto volá, musí mít req , res a next ("kontext" Express).
    • Musíte také předpokládat sekvenci volání a next() , takže i když se může jednat o jednotlivé funkce, nelze je znovu použít.
  • Pokud musíte předat jednu hodnotu z jedné middlewarové funkce do další, musíte použít res.locals to (když bychom to mohli vrátit a předat přes argument funkce).
  • Ztěžuje to psaní automatických testů.

Požadavek na kontextovou závislost

Jedním z největších problémů podle mého názoru je, že tyto funkce nejsou znovu použitelné. Protože definice funkce je nyní prostřednictvím svých argumentů spojena s req , res a next , a ty jsou propojeny s ExpressJS, nemůžete je volat nikde jinde v kódu. Pokud to není někde, kde máte kontext ExpressJS (více o tom o něco níže).

Pokud by se jednalo pouze o „běžné“ funkce, na kontextu by nezáleželo. To znamená, že pokud byste mohli pouze předat „agnostické“ hodnoty/objekty/pole atd., mohli byste je znovu použít jinde ve svém kódu. Jistě, na očekávaných typech a očekávaných argumentech záleží, ale funkce můžete znovu použít způsoby, které mají smysl pro své aplikace. Své funkce utility můžete volat například v kódu servisní vrstvy nebo v kódu databáze. A samozřejmě na obchodní logice stále záleží, tj. nebudete svévolně volat funkce. Podobně nebudete volat funkce ovladače i z jiného ovladače.

Ale tím, že nejsme zcela propojeni se základními objekty/funkcemi Express, se dostáváme daleko k opětovné použitelnosti. Při navrhování našeho softwaru bychom měli vždy usilovat o volné propojení.

Tento middleware možná budete moci „znovu použít“ jinde, ale pouze jako middleware a ani pak nemusí být znovu použitelný. Zvažte funkci, která má ukončit požadavek voláním res.send(response) . Ve skutečnosti to nemůžete znovu použít (beze změny definice funkce), protože to ukončí požadavek, takže ho nemůžete zavolat uprostřed řetězce. A pokud potřebujete předat hodnoty z jedné middlewarové funkce do dále se tato opětovná použitelnost pesudo-middlewaru stává ještě obtížnější, jak je vysvětleno v další části.

Předávání hodnot z jedné funkce do další

V našem výše uvedeném kódu getCustomerData() volá fetchCustomerTransactionHistory() a poté jej potřebuje předat další middlewarové funkci, processOrder() . Protože jsou tyto funkce volány v řetězci, potřebujeme nějaký způsob předání této hodnoty do processOrder() , protože nemáme žádnou zprostředkující proměnnou, do které bychom výsledek uložili.

Můžete to udělat pomocí res.locals.transactionHistory = transactionHistory nebo připojením nové vlastnosti k res objekt libovolně, například res.transactionHistory = transactionHistory .Jakákoli vlastnost přidána do res.locals je k dispozici pouze po dobu životního cyklu požadavku, takže po dokončení požadavku k němu již nebudete mít přístup.

To je mnohem složitější, než kdybychom mohli zavolat getCustomerData() , uložte výsledek do proměnné customerData nebo cokoliv jiného a pak to předejte processOrder() .

To také dále zdůrazňuje, že na pořadí volání funkcí middlewaru záleží, když na to půjdete tímto způsobem. Protože jedna funkce se bude spoléhat na předchozí res.locals Po nastavení musí pořadí volání zůstat stejné. A pokud chcete změnit předávanou hodnotu, musíte nevyhnutelně změnit implementaci více než jedné funkce, nemůžete změnit pouze jednu funkci.

Zatímco res.locals je podporován ExpressJS a můžete samozřejmě nastavit nové vlastnosti objektů, pokud přejdete na vlastní vlastnost na res route, nedoporučuji to, pokud to není něco, co absolutně musíte udělat, protože to může ztížit odstraňování problémů. Ale každopádně je nejlepší se tomu úplně vyhnout a mít logiku utilit/obchodu/DB v nemiddlewarovém kódu.

Ztěžuje psaní automatických testů

Abychom mohli napsat testy pro tento typ kódu, musíme nyní buď stub req a res nebo potřebujeme otestovat tento end-to-end pomocí něčeho jako supertest. Endpoint/end-to-end testy je dobré mít, ale tyto funkce, které chceme testovat, jsou individuální/modulární (nebo by alespoň měly být modulární/opakovatelné ) a mělo by být možné testovat více jako jednotky. Neměli bychom je muset testovat tím, že spustíme simulovaný server nebo ručně vytlačíme req a res - to je zbytečná složitost a práce. A pahýly pro objekty požadavků a odpovědí mohou vyžadovat větší údržbu, těsné spojení atd. Ne, že by pahýly byly špatné - právě naopak - a v případě funkcí výše bychom pravděpodobně chtěli nějaké přerušit databáze a asynchronních volání. Ale v tomto případě je nechceme psát pro req /res . Musely by to být spíše makety, kde definujeme next() funkce a prohlásit, že byla volána, útržek res.send() funkce, což je implementace, která nás nezajímá atd.

Místo toho, kdybychom mohli tyto pesudo-middleware rozdělit na obnovitelné funkce bez kontextu ExpressJS, mohli bychom je otestovat předáním očekávaných parametrů funkcím, což značně usnadňuje nastavení testu.

K čemu je vlastně middleware

Toto téma by samo o sobě mohlo obsahovat několik blogpostů, ale pro získání obecné představy o middlewaru by se mělo používat pro věci, které jsou společné všem požadavkům HTTP, ale neobsahují obchodní logiku a které je třeba zpracovat před vším ostatním.

Věci jako:

  • Autorizace/ověření
  • Ukládání do mezipaměti
  • Údaje o relacích
  • CORS
  • Protokolování požadavků HTTP (jako morgan)

Všechny výše uvedené jsou vlastní kategorií zájmu o API, koncepčně oddělené od kódu, který se zabývá načítáním dat z databáze, zasíláním e-mailu s registrací uživatele atd. Před přístupem uživatele nebo klientské aplikace ke službě musí proběhnout autorizace a ověření. . To je něco, co je společné pro všechny (nebo většinu) požadavků. Ukládání do mezipaměti, které je obecně společné pro většinu požadavků a je to nástroj, který se netýká obchodní nebo pohledové logiky. Totéž s daty relace, totéž s CORS, totéž s požadavkem protokolování.

I když vždy existují výjimky z jakéhokoli pravidla, middleware by téměř vždy neměl obsahovat jádro vašeho kódu, který zpracovává obchodní logiku, který zpracovává kód specifický pro vaše REST API, tedy „dále“ v řetězci volání funkcí.

Rád přemýšlím o obchodní logice jako o „čistší“ formě logiky. Je to logika, která by se neměla starat o ověření požadavku nebo zpracování čehokoli specifického pro rámec. Zabývá se pouze algoritmy/pravidly pro zpracování dat, ukládání dat, načítání dat, formátování těchto dat atd. Tato pravidla jsou obvykle určena obchodními požadavky.

Pokud byste například měli rozhraní API, které vrátilo, kolik uživatelů bylo na vaší platformě zaregistrováno za posledních X dní, obchodní logika by zde dotazovala databázi a provedla jakékoli formátování těchto dat, než je vrátí řadiči. , která vrací odpověď HTTP. Tato logika nezpracovává data do mezipaměti, ověřování nebo relace. O to se postará middleware.

Jak to opravit

Pokud uděláme tyto „normální“ funkce spíše než „middlewarové“ funkce spojené s ExpressJS, mohly by vypadat takto. Samozřejmě byste to mohli dále refaktorovat, ale toto je obecná myšlenka:

const getCustomerData = async (customerId) => {
  const customerDetails = await fetchUserDetails(customerId)

  return fetchCustomerTransactionHistory(customerDetails)
}

const processOrder = async (orderTotal, orderItems, paymentDetails, transactionHistory) => {
  const customerDiscount = await calculateDiscountFromCustomerTransactionHistory(transactionHistory)

  let recalculatedOrderTotal

  if (customerDiscount) {
    recalculatedOrderTotal = orderTotal - (orderTotal * customerDiscount)
  }

  const itemsAreInStock = await doubleCheckStock(orderItems)

  if (!itemsAreInStock) {
    return null
  }

  await insertOrder(orderTotal, orderItems)
  return chargeCustomerPayment(recalculatedOrderTotal || orderTotal, paymentDetails)
}

const sendConfirmationEmailToCustomer = (customerId, orderItems) => {
  return dispatchConfirmationEmailJob(customerId, orderItems)
}

Poznámka:sendConfirmationEmailToCustomer() je v podstatě jen funkce wrapper. Mohli bychom zavolat dispatchConfirmationEmailJob() přímo teď, ale nechávám to na demonstraci před a po.

Nyní máme funkce, které jsou více opakovaně použitelné, nejsou propojené s ExpressJS a vyžadují méně testovacího nastavení pro psaní testů.

Tyto funkce ve vašem ovladači můžete nazvat takto:

// Controller
const createOrder = async (req, res, next) => {
  const {customerId, orderTotal, orderItems, paymentDetails} = req.body
  
  try {
    const customerData = await getCustomerData(customerId)
    await processOrder(orderTotal, orderItems, paymentDetails, customerData)
    await sendConfirmationEmailToCustomer(customerId, orderItems)

    res.sendStatus(201)

    return
  } catch (err) {
    res.sendStatus(500) // or however you want to handle it

    return
  }
}

// Route
router.post('/order', createOrder)

Tyto jednotlivé funkce můžete samozřejmě použít i jinde ve svém kódu, když jsou nyní znovu použitelné!

Přihlaste se k odběru všech nových příspěvků přímo do vaší schránky!

Nikdy žádný spam. Odběr můžete kdykoli odhlásit.