Ответ 1
Контроллеры - это объекты Бога, пока вы не хотите, чтобы они были такими... - вы не говорите zurfyx (╯ ° □ °) ╯ (┻━┻
Просто интересно решение? Перейти на последний раздел "Результат" .
┬──┬◡ ノ (° - ° ノ)
Прежде чем начать с ответа, позвольте мне извиниться за то, что этот ответ намного длиннее обычной длины SO. Контроллеры сами по себе ничего не делают, все о шаблоне MVC. Итак, мне показалось, что важно проследить все важные подробности о Router ↔ Controller ↔ Service ↔ Model, чтобы показать вам, как достичь надлежащих изолированных контроллеров с минимальными обязанностями.
Гипотетический случай
Начнем с небольшого гипотетического случая:
- Я хочу иметь API, который служит для поиска пользователей через AJAX.
- Я хочу иметь API, который также обслуживает один и тот же поиск пользователя через Socket.io.
Начнем с Express. Это простое peasy, не так ли?
routes.js
import * as userControllers from 'controllers/users';
router.get('/users/:username', userControllers.getUser);
Контроллеры/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);
}
}
Теперь давайте сделаем раздел Socket.io:
Поскольку это не вопрос socket.io, я пропущу шаблон.
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);
}
});
Ух, здесь что-то пахнет...
-
if (username === '')
. Нам пришлось дважды написать контролер проверки. Что делать, если были проверенные валидаторыn
? Должны ли мы сохранить две (или более) копии каждого обновления? -
User.find({ username })
повторяется дважды. Это может быть услуга.
Мы только что написали два контроллера, которые привязаны к точным определениям Express и Socket.io соответственно. Они, скорее всего, никогда не сломаются во время их жизни, потому что и Express, и Socket.io имеют обратную совместимость. НО, они не могут использоваться повторно. Изменение Express для Hapi? Вам придется переделать все ваши контроллеры.
Еще один неприятный запах, который может быть не столь очевидным...
Ответ контроллера - ручной. .json({ error: whatever })
API в RL постоянно меняются. В будущем вам может потребоваться, чтобы ваш ответ был { err: whatever }
или, может быть, более сложным (и полезным), например: { error: whatever, status: 500 }
Пусть начнется (возможное решение)
Я не могу назвать это решением, потому что есть бесконечное количество решений. Это зависит от вашего творчества и ваших потребностей. Следующее - достойное решение; Я использую его в относительно большом проекте и, похоже, работает хорошо, и он исправляет все, что я указывал раньше.
Пойду Model → Service → Controller → Router, чтобы он был интересным до конца.
Model
Я не буду вдаваться в подробности о Модели, потому что это не предмет вопроса.
Вы должны иметь аналогичную структуру модели Mongoose следующим образом:
модели/User/validate.js
export function validateUsername(username) {
return true;
}
Вы можете узнать больше о соответствующей структуре для валидаторов mongoose 4.x здесь.
модели/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;
Просто базовая пользовательская схема с полем имени пользователя и created
updated
полями, контролируемыми мангустами.
Причина, по которой я включил поле validate
, здесь, чтобы вы заметили, что вы должны выполнять большую часть проверки модели здесь, а не в контроллере.
Mongoose Schema - последний шаг перед достижением базы данных, если только кто-то не запросит MongoDB напрямую, вы всегда будете уверены, что все пройдут проверку вашей модели, что дает вам больше безопасности, чем размещение их на вашем контроллере. Не говоря уже о том, что валидаторы модульного тестирования, как они в предыдущем примере, тривиальны.
Подробнее об этом здесь и здесь.
Сервис
Служба будет действовать как процессор. Учитывая приемлемые параметры, он обработает их и вернет значение.
В большинстве случаев (включая этот) он использует Mongoose Models и возвращает Promise (или обратный вызов, но Я бы определенно использовал ES6 с Promises, если вы уже этого не делаете).
услуги/user.js
function getUser(username) {
return User.find({ username}).exec(); // Just as a mongoose reminder, .exec() on find
// returns a Promise instead of the standard callback.
}
На этом этапе вам может быть интересно, нет catch
блока? Нет, потому что мы собираемся сделать крутой трюк позже, и нам не нужен пользовательский для этого случая.
В других случаях достаточно тривиальной службы синхронизации. Убедитесь, что ваша служба синхронизации никогда не включает ввод-вывод, иначе вы будете блокировать целую цепочку Node.js.
услуги/user.js
function isChucknorris(username) {
return ['Chuck Norris', 'Jon Skeet'].indexOf(username) !== -1;
}
контроллер
Мы хотим избежать дублирования контроллеров, поэтому для каждого действия у нас будет только a.
Контроллеры/user.js
export function getUser(username) {
}
Как выглядит эта подпись сейчас? Довольно, верно? Поскольку нас интересует только параметр имени пользователя, нам не нужно принимать бесполезные вещи, такие как req, res, next
.
Добавьте в отсутствующие валидаторы и службу:
Контроллеры/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);
}
По-прежнему выглядит аккуратно, но... как насчет throw new Error
, разве это не приведет к сбою моего приложения? -Ты, подожди. Мы еще не закончили.
Итак, в этот момент наша документация контроллера будет выглядеть примерно так:
/**
* Get a user by username.
* @param username a string value that represents user username.
* @returns A Promise, an exception or a value.
*/
Какое значение указано в @returns
? Помните, что раньше мы говорили, что наши сервисы могут быть как синхронными, так и асинхронными (с использованием Promise
)? getUserService
является асинхронным в этом случае, но isChucknorris
служба не будет, поэтому он просто вернет значение вместо обещания.
Надеемся, что все прочтут документы. Потому что им нужно будет обрабатывать некоторые контроллеры, отличные от других, и для некоторых из них потребуется блок try-catch
.
Так как мы не можем доверять разработчикам (это включает меня), читая документы перед первым попыткой, на этом этапе мы должны принять решение:
- Контроллеры для принудительного возврата
Promise
- Сервис всегда возвращает обещание
⬑ Это позволит решить несогласованный возврат контроллера (а не тот факт, что мы можем опустить наш блок try-catch).
IMO, я предпочитаю первый вариант. Потому что контроллеры - это те, которые в большинстве случаев будут связывать большинство Promises.
return findUserByUsername
.then((user) => getChat(user))
.then((chat) => doSomethingElse(chat))
Если мы используем ES6 Promise, мы можем в качестве альтернативы использовать красивое свойство Promise
для этого: Promise
может обрабатывать не promises в течение своей жизни и все равно продолжать возвращать Promise
:
return promise
.then(() => nonPromise)
.then(() => // I can keep on with a Promise.
Если единственная услуга, которую мы вызываем, не использует Promise
, мы можем сделать ее сами.
return Promise.resolve() // Initialize Promise for the first time.
.then(() => isChucknorris('someone'));
Возвращаясь к нашему примеру, это приведет к:
...
return Promise.resolve()
.then(() => getUserService(username));
Нам действительно не нужно Promise.resolve()
в этом случае, поскольку getUserService
уже возвращает Promise, но мы хотим быть последовательными.
Если вы задаетесь вопросом о блоке catch
: мы не хотим использовать его в нашем контроллере, если мы не хотим сделать это обычным образом. Таким образом, мы можем использовать два уже встроенных канала связи (исключение для ошибок и возврат сообщений о успехе) для доставки наших сообщений по отдельным каналам.
Вместо ES6 Promise .then
мы можем использовать в наших контроллерах новый ES2017 async / await
(теперь официальный):
async function myController() {
const user = await findUserByUsername();
const chat = await getChat(user);
const somethingElse = doSomethingElse(chat);
return somethingElse;
}
Обратите внимание на async
перед function
.
маршрутизатор
Наконец, маршрутизатор, yay!
Итак, мы еще ничего не ответили пользователю, все, что у нас есть, - это контроллер, который знает, что он ВСЕГДА возвращает Promise
(надеюсь, с данными). Oh!, и это может вызвать исключение, если throw new Error is called
или некоторая служба Promise
ломается.
Роутером будет тот, который будет единообразно управлять петициями и возвращать данные клиентам, будь то некоторые существующие данные, null
или undefined
data
или ошибка.
Маршрутизатор будет ТОЛЬКО, который будет иметь несколько определений. Количество которых будет зависеть от наших перехватчиков. В гипотетическом случае это были API (с Express) и Socket (с Socket.io).
Давайте рассмотрим, что мы должны делать:
Мы хотим, чтобы наш маршрутизатор преобразовал (req, res, next)
в (username)
. Наивная версия будет примерно такой:
router.get('users/:username', (req, res, next) => {
try {
const result = await getUser(req.params.username); // Remember: getUser is the controller.
return res.status(200).json(result);
} catch (error) {
return res.status(500).json(error);
}
});
Хотя это будет хорошо работать, это приведет к огромному дублированию кода, если мы скопируем этот фрагмент во всех наших маршрутах. Поэтому мы должны сделать лучшую абстракцию.
В этом случае мы можем создать своего рода фальшивый клиент-маршрутизатор, который выполняет обещание и параметры n
, выполняет задачи маршрутизации и return
, как это было бы в каждом из маршрутов.
/**
* Handles controller execution and responds to user (API Express version).
* Web socket has a similar handler implementation.
* @param promise Controller Promise. I.e. getUser.
* @param params A function (req, res, next), all of which are optional
* that maps our desired controller parameters. I.e. (req) => [req.params.username, ...].
*/
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; // Just a name shortener.
Если вам интересно узнать больше об этом трюке, вы можете прочитать о полной версии этого в моем другом ответе в React-Redux и Websockets с socket.io (Раздел "SocketClient.js" ).
Как выглядит ваш маршрут с помощью controllerHandler
?
router.get('users/:username', c(getUser, (req, res, next) => [req.params.username]));
Чистая одна строка, как и в начале.
Дополнительные необязательные шаги
Контроллер Promises
Это относится только к тем, кто использует ES6 Promises. Версия ES2017 async / await
уже выглядит хорошо для меня.
По какой-то причине мне не нравится использовать имя Promise.resolve()
для создания инициализации Promise. Просто не ясно, что там происходит.
Я бы скорее заменил их на что-то более понятное:
const chain = Promise.resolve(); // Write this as an external imported variable or a global.
chain
.then(() => ...)
.then(() => ...)
Теперь вы знаете, что chain
обозначает начало цепочки Promises. Так же, как и все, кто читает ваш код, а если нет, они, по крайней мере, считают его цепочкой функций обслуживания.
Экспресс-обработчик ошибок
У Express есть обработчик ошибок по умолчанию, который вы должны использовать для захвата, по крайней мере, самых неожиданных ошибок.
router.use((err, req, res, next) => {
// Expected errors always throw Error.
// Unexpected errors will either throw unexpected stuff or crash the application.
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: '⁽ƈ ͡ (ुŏ̥̥̥̥םŏ̥̥̥̥) ु' });
});
Что еще, вы, вероятно, должны использовать что-то вроде debug или winston вместо console.error
, которые являются более профессиональными способами обработки журналов.
И вот как мы подключаем это к controllerHandler
:
...
} catch (error) {
return res.status(500) && next(error);
}
Мы просто перенаправляем любую обработанную ошибку для обработчика ошибок Express.
Ошибка как ApiError
Error
считается классом по умолчанию для инкапсуляции ошибок при бросании исключения в Javascript. Если вы действительно хотите отслеживать свои собственные контролируемые ошибки, я бы, вероятно, изменил обработчик ошибок throw Error
и Express от Error
до ApiError
, и вы даже можете улучшить его, добавив его статус поле.
export class ApiError {
constructor(message, status = 500) {
this.message = message;
this.status = status;
}
}
Дополнительная информация
Пользовательские исключения
Вы можете бросить любое настраиваемое исключение в любой точке throw new Error('whatever')
или с помощью new Promise((resolve, reject) => reject('whatever'))
. Вам просто нужно играть с Promise
.
ES6 ES2017
Это очень упрямый момент. IMO ES6 (или даже ES2017, теперь имеющий официальный набор функций) является подходящим способом работы с большими проектами на основе Node.
Если вы еще не используете его, попробуйте посмотреть ES6 и ES2017 и Babel transpiler.
Результат
Это всего лишь полный код (уже показанный ранее) без комментариев или комментариев. Вы можете проверить все, что касается этого кода, прокручивая до соответствующего раздела.
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]));
Контроллеры/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;
}
услуги/user.js
import User from '../models/User';
export function getUser(username) {
return User.find({}).exec();
}
модели/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;
модели/User/validate.js
export function validateUsername(username) {
return true;
}