Controller sind Gottobjekte, bis Sie nicht möchten, dass sie so sind ...
- Sie sagen nicht zurfyx (╯ ° □ °)))
Nur an der Lösung interessiert? Springe zum neuesten Abschnitt "Ergebnis" .
┬──┬◡ ノ (° - ° ノ)
Bevor ich mit der Antwort beginne, möchte ich mich dafür entschuldigen, dass diese Antwort viel länger als die übliche SO-Länge ist. Controller alleine machen nichts, es geht nur um das gesamte MVC-Muster. Daher hielt ich es für wichtig, alle wichtigen Details zum Router <-> Controller <-> Service <-> Modell durchzugehen, um Ihnen zu zeigen, wie Sie geeignete isolierte Controller mit minimalen Verantwortlichkeiten erreichen.
Hypothetischer Fall
Beginnen wir mit einem kleinen hypothetischen Fall:
- Ich möchte eine API haben, die eine Benutzersuche über AJAX ermöglicht.
- Ich möchte eine API haben, die auch die gleiche Benutzersuche über Socket.io ermöglicht.
Beginnen wir mit Express. Das ist einfach peasy, nicht wahr?
route.js
import * as userControllers from 'controllers/users';
router.get('/users/:username', userControllers.getUser);
controller / user.js
import User from '../models/User';
function getUser(req, res, next) {
const username = req.params.username;
if (username === '') {
return res.status(500).json({ error: 'Username can\'t be blank' });
}
try {
const user = await User.find({ username }).exec();
return res.status(200).json(user);
} catch (error) {
return res.status(500).json(error);
}
}
Jetzt machen wir den Socket.io-Teil:
Da dies keine socket.io- Frage ist, überspringe ich das Boilerplate.
import User from '../models/User';
socket.on('RequestUser', (data, ack) => {
const username = data.username;
if (username === '') {
ack ({ error: 'Username can\'t be blank' });
}
try {
const user = User.find({ username }).exec();
return ack(user);
} catch (error) {
return ack(error);
}
});
Ähm, hier riecht etwas ...
if (username === '')
. Wir mussten den Controller-Validator zweimal schreiben. Was wäre, wenn es n
Controller-Validatoren gäbe ? Müssten wir zwei (oder mehr) Kopien von jeder auf dem neuesten Stand halten?
User.find({ username })
wird zweimal wiederholt. Das könnte möglicherweise eine Dienstleistung sein.
Wir haben gerade zwei Controller geschrieben, die den genauen Definitionen von Express und Socket.io zugeordnet sind. Sie werden höchstwahrscheinlich während ihres Lebens niemals kaputt gehen, da sowohl Express als auch Socket.io in der Regel abwärtskompatibel sind. ABER sie sind nicht wiederverwendbar. Express für Hapi wechseln ? Sie müssen alle Ihre Controller wiederholen.
Ein weiterer schlechter Geruch, der vielleicht nicht so offensichtlich ist ...
Die Controller-Antwort ist handgefertigt. .json({ error: whatever })
APIs in RL ändern sich ständig. In Zukunft möchten Sie vielleicht, dass Ihre Antwort { err: whatever }
oder etwas Komplexeres (und Nützlicheres) wie:{ error: whatever, status: 500 }
Fangen wir an (eine mögliche Lösung)
Ich kann es nicht die Lösung nennen, weil es unendlich viele Lösungen gibt. Es liegt an Ihrer Kreativität und Ihren Bedürfnissen. Das Folgende ist eine anständige Lösung; Ich verwende es in einem relativ großen Projekt und es scheint gut zu funktionieren, und es behebt alles, worauf ich zuvor hingewiesen habe.
Ich gehe zu Modell -> Service -> Controller -> Router, um es bis zum Ende interessant zu halten.
Modell
Ich werde nicht auf Details des Modells eingehen, da dies nicht Gegenstand der Frage ist.
Sie sollten eine ähnliche Mungo-Modellstruktur wie die folgende haben:
models / User / validate.js
export function validateUsername(username) {
return true;
}
Weitere Informationen zur geeigneten Struktur für Mungo 4.x-Validatoren finden Sie hier .
models / User / index.js
import { validateUsername } from './validate';
const userSchema = new Schema({
username: {
type: String,
unique: true,
validate: [{ validator: validateUsername, msg: 'Invalid username' }],
},
}, { timestamps: true });
const User = mongoose.model('User', userSchema);
export default User;
Nur ein einfaches Benutzerschema mit einem Benutzernamenfeld und created
updated
mungogesteuerten Feldern.
Der Grund, warum ich das validate
Feld hier eingefügt habe, ist, dass Sie feststellen, dass Sie die meisten Modellvalidierungen hier und nicht im Controller durchführen sollten.
Das Mungo-Schema ist der letzte Schritt vor dem Erreichen der Datenbank. Wenn jemand MongoDB nicht direkt abfragt, können Sie immer sicher sein, dass jeder Ihre Modellvalidierungen durchläuft, was Ihnen mehr Sicherheit bietet, als sie auf Ihrem Controller zu platzieren. Nicht zu sagen, dass Unit-Test-Validatoren wie im vorherigen Beispiel trivial sind.
Lesen Sie hier und hier mehr darüber .
Bedienung
Der Dienst fungiert als Prozessor. Bei akzeptablen Parametern werden diese verarbeitet und ein Wert zurückgegeben.
In den meisten Fällen (einschließlich dieses) werden Mongoose-Modelle verwendet und ein Versprechen zurückgegeben (oder ein Rückruf; aber ich würde ES6 definitiv mit Versprechen verwenden, wenn Sie dies nicht bereits tun).
services / user.js
function getUser(username) {
return User.find({ username}).exec();
}
An diesem Punkt wundern Sie sich vielleicht, kein catch
Block? Nein, weil wir später einen coolen Trick machen werden und für diesen Fall keinen benutzerdefinierten brauchen.
In anderen Fällen reicht ein einfacher Synchronisierungsdienst aus. Stellen Sie sicher, dass Ihr Synchronisierungsdienst niemals E / A enthält, da Sie sonst den gesamten Node.js-Thread blockieren .
services / user.js
function isChucknorris(username) {
return ['Chuck Norris', 'Jon Skeet'].indexOf(username) !== -1;
}
Regler
Wir möchten doppelte Controller vermeiden, daher haben wir nur einen Controller für jede Aktion.
controller / user.js
export function getUser(username) {
}
Wie sieht diese Signatur jetzt aus? Schön, oder? Da wir nur am Parameter username interessiert sind, müssen wir keine nutzlosen Dinge wie nehmen req, res, next
.
Fügen wir die fehlenden Validatoren und den fehlenden Service hinzu:
controller / user.js
import { getUser as getUserService } from '../services/user.js'
function getUser(username) {
if (username === '') {
throw new Error('Username can\'t be blank');
}
return getUserService(username);
}
Sieht immer noch ordentlich aus, aber ... was ist mit dem throw new Error
, bringt das meine Anwendung nicht zum Absturz? - Shh, warte. Wir sind noch nicht fertig.
An diesem Punkt würde unsere Controller-Dokumentation also ungefähr so aussehen:
Was ist der "Wert" in der angegeben @returns
? Denken Sie daran, dass wir vorhin gesagt haben, dass unsere Dienste sowohl synchron als auch asynchron sein können (mit Promise
)? getUserService
ist in diesem Fall asynchron, der isChucknorris
Dienst jedoch nicht. Daher wird einfach ein Wert anstelle eines Versprechens zurückgegeben.
Hoffentlich wird jeder die Dokumente lesen. Weil sie einige Controller anders behandeln müssen als andere, und einige von ihnen benötigen einen try-catch
Block.
Da wir Entwicklern (einschließlich mir) nicht vertrauen können, dass sie die Dokumente lesen, bevor sie es zuerst versuchen, müssen wir an dieser Stelle eine Entscheidung treffen:
- Controller, um eine
Promise
Rückgabe zu erzwingen
- Service, um immer ein Versprechen zurückzugeben
⬑ Dies löst die inkonsistente Controller-Rückgabe (nicht die Tatsache, dass wir unseren Try-Catch-Block weglassen können).
IMO, ich bevorzuge die erste Option. Weil Controller meistens die meisten Versprechen verketten.
return findUserByUsername
.then((user) => getChat(user))
.then((chat) => doSomethingElse(chat))
Wenn wir ES6 Promise verwenden, können wir alternativ eine nette Eigenschaft nutzen, Promise
um dies zu tun: Wir können Promise
Nicht-Versprechen während ihrer Lebensdauer verarbeiten und trotzdem immer wieder Folgendes zurückgeben Promise
:
return promise
.then(() => nonPromise)
.then(() =>
Wenn der einzige Dienst, den wir anrufen, nicht verwendet wird Promise
, können wir selbst einen erstellen.
return Promise.resolve()
.then(() => isChucknorris('someone'));
Wenn wir zu unserem Beispiel zurückkehren, würde dies Folgendes ergeben:
...
return Promise.resolve()
.then(() => getUserService(username));
Wir brauchen Promise.resolve()
in diesem Fall nicht wirklich, da getUserService
bereits ein Versprechen zurückgegeben wird, aber wir wollen konsistent sein.
Wenn Sie sich über den catch
Block wundern : Wir möchten ihn nicht in unserem Controller verwenden, es sei denn, wir möchten eine benutzerdefinierte Behandlung durchführen. Auf diese Weise können wir die beiden bereits integrierten Kommunikationskanäle (Ausnahme für Fehler und Rückgabe für Erfolgsmeldungen) nutzen, um unsere Nachrichten über einzelne Kanäle zu übermitteln.
Anstelle von ES6 Promise .then
können wir das neuere ES2017 async / await
( jetzt offiziell ) in unseren Controllern verwenden:
async function myController() {
const user = await findUserByUsername();
const chat = await getChat(user);
const somethingElse = doSomethingElse(chat);
return somethingElse;
}
Beachten Sie async
vor dem function
.
Router
Endlich der Router, yay!
Wir haben dem Benutzer also noch nichts geantwortet. Wir haben lediglich einen Controller, von dem wir wissen, dass er IMMER einen Promise
(hoffentlich mit Daten) zurückgibt . Oh!, Und das kann möglicherweise eine Ausnahme auslösen, wenn throw new Error is called
oder einige Service- Promise
Pausen.
Der Router wird denjenigen, der sie in einer einheitlichen Art und Weise, Steuer Petitionen und Rückgabedaten zu Kunden, einige vorhandenen Daten sein, null
oder undefined
data
oder ein Fehler.
Der Router ist der EINZIGE, der mehrere Definitionen hat. Die Anzahl hängt von unseren Abfangjägern ab. Im hypothetischen Fall waren dies API (mit Express) und Socket (mit Socket.io).
Lassen Sie uns überprüfen, was wir tun müssen:
Wir möchten, dass unser Router in konvertiert (req, res, next)
wird (username)
. Eine naive Version wäre ungefähr so:
router.get('users/:username', (req, res, next) => {
try {
const result = await getUser(req.params.username);
return res.status(200).json(result);
} catch (error) {
return res.status(500).json(error);
}
});
Obwohl es gut funktionieren würde, würde dies zu einer enormen Menge an Codeduplizierungen führen, wenn wir dieses Snippet auf allen unseren Routen kopieren und einfügen würden. Wir müssen also eine bessere Abstraktion machen.
In diesem Fall können wir eine Art gefälschten Router-Client erstellen, der ein Versprechen und n
Parameter einhält und dessen Routing und return
Aufgaben genau wie auf jeder der Routen ausführt.
const controllerHandler = (promise, params) => async (req, res, next) => {
const boundParams = params ? params(req, res, next) : [];
try {
const result = await promise(...boundParams);
return res.json(result || { message: 'OK' });
} catch (error) {
return res.status(500).json(error);
}
};
const c = controllerHandler;
Wenn Sie mehr über diesen Trick erfahren möchten , können Sie die Vollversion in meiner anderen Antwort in React-Redux und Websockets mit socket.io (Abschnitt "SocketClient.js") nachlesen.
Wie würde Ihre Route mit dem aussehen controllerHandler
?
router.get('users/:username', c(getUser, (req, res, next) => [req.params.username]));
Eine saubere Zeile, genau wie am Anfang.
Weitere optionale Schritte
Controller verspricht
Dies gilt nur für diejenigen, die ES6-Versprechen verwenden. Die ES2017- async / await
Version sieht für mich bereits gut aus.
Aus irgendeinem Grund mag ich es nicht, Promise.resolve()
Namen verwenden zu müssen, um das Initialisierungsversprechen zu erstellen. Es ist einfach nicht klar, was dort los ist.
Ich möchte sie lieber durch etwas Verständlicheres ersetzen:
const chain = Promise.resolve();
chain
.then(() => ...)
.then(() => ...)
Jetzt wissen Sie, dass dies chain
den Beginn einer Kette von Versprechen markiert. Jeder, der Ihren Code liest, oder wenn nicht, geht er zumindest davon aus, dass es sich um eine Kette handelt, die ein Service funktioniert.
Express-Fehlerbehandlungsroutine
Express verfügt über eine Standardfehlerbehandlungsroutine, mit der Sie mindestens die unerwartetsten Fehler erfassen sollten.
router.use((err, req, res, next) => {
if (Object.prototype.isPrototypeOf.call(Error.prototype, err)) {
return res.status(err.status || 500).json({ error: err.message });
}
console.error('~~~ Unexpected error exception start ~~~');
console.error(req);
console.error(err);
console.error('~~~ Unexpected error exception end ~~~');
return res.status(500).json({ error: '⁽ƈ ͡ (ुŏ̥̥̥̥םŏ̥̥̥̥) ु' });
});
Was mehr ist, sollten Sie wahrscheinlich etwas wie Debug oder Winston anstelle von verwenden console.error
, die professionellere Methoden zum Umgang mit Protokollen sind.
Und so stecken wir das in controllerHandler
:
...
} catch (error) {
return res.status(500) && next(error);
}
Wir leiten jeden erfassten Fehler einfach an den Fehlerbehandler von Express weiter.
Fehler als ApiError
Error
wird als Standardklasse zum Einkapseln von Fehlern beim Auslösen einer Ausnahme in Javascript angesehen. Wenn Sie wirklich nur Ihre eigenen kontrollierten Fehler verfolgen möchten, würde ich wahrscheinlich den throw Error
und den Express-Fehlerhandler von Error
auf ändern ApiError
, und Sie können ihn sogar besser an Ihre Bedürfnisse anpassen, indem Sie ihm das Statusfeld hinzufügen.
export class ApiError {
constructor(message, status = 500) {
this.message = message;
this.status = status;
}
}
Zusätzliche Information
Benutzerdefinierte Ausnahmen
Sie können jede benutzerdefinierte Ausnahme jederzeit mit throw new Error('whatever')
oder mithilfe von auslösen new Promise((resolve, reject) => reject('whatever'))
. Du musst nur damit spielen Promise
.
ES6 ES2017
Das ist ein sehr einfühlsamer Punkt. IMO ES6 (oder sogar ES2017 , das jetzt über offizielle Funktionen verfügt) ist die geeignete Methode, um an großen Projekten zu arbeiten, die auf Node basieren.
Wenn Sie es noch nicht verwenden, schauen Sie sich die ES6- Funktionen sowie den ES2017- und Babel- Transpiler an.
Ergebnis
Dies ist nur der vollständige Code (bereits zuvor gezeigt) ohne Kommentare oder Anmerkungen. Sie können alles in Bezug auf diesen Code überprüfen, indem Sie zum entsprechenden Abschnitt scrollen.
router.js
const controllerHandler = (promise, params) => async (req, res, next) => {
const boundParams = params ? params(req, res, next) : [];
try {
const result = await promise(...boundParams);
return res.json(result || { message: 'OK' });
} catch (error) {
return res.status(500) && next(error);
}
};
const c = controllerHandler;
router.get('/users/:username', c(getUser, (req, res, next) => [req.params.username]));
controller / user.js
import { serviceFunction } from service/user.js
export async function getUser(username) {
const user = await findUserByUsername();
const chat = await getChat(user);
const somethingElse = doSomethingElse(chat);
return somethingElse;
}
services / user.js
import User from '../models/User';
export function getUser(username) {
return User.find({}).exec();
}
models / User / index.js
import { validateUsername } from './validate';
const userSchema = new Schema({
username: {
type: String,
unique: true,
validate: [{ validator: validateUsername, msg: 'Invalid username' }],
},
}, { timestamps: true });
const User = mongoose.model('User', userSchema);
export default User;
models / User / validate.js
export function validateUsername(username) {
return true;
}