Создано: May 11, 2022 2:41 AM
Опубликовано: Да
Настройка
Мы инициализируем наш проект node в выбранной нами директории, создадим директорию сервера и файл server.js для нашего сервера node
npm init -y
mkdir server
touch server/server.js
Мы устанавливаем babel
и webpack
dev-зависимости для пакетирования и минификации нашего кода для возможного производства
npm install -D @babel/core @babel/preset-env @babel/preset-react babel-loader webpack webpack-cli webpack-node-externals
💡 webpack-node-externals
не позволяет нам подключать модули из папки node_modules к нашему коду, так как это сделает его раздутым. Webpack
будет загружать модули из папки node_modules и подключать их. Это хорошо для кода фронтенда, но модули бэкенда обычно не готовы к этому. Подробнее здесь
Затем мы настроим webpack
, создав webpack.config.js
в корне каталога нашего проекта.
const path = require('path');
const CURRENT_WORKING_DIR = process.cwd();
const nodeExternals = require('webpack-node-externals');
const config = {
name: "server",
entry: [ path.join(CURRENT_WORKING_DIR, '/server/server.js')],
target: "node",
output: {
path: path.join(CURRENT_WORKING_DIR, '/dist/'),
filename: "server.generated.js",
publicPath: '/dist/',
libraryTarget: "commonjs",
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /.js$/,
exclude: /node_modules/,
use: ['babel-loader']
}
]
}
};
module.exports = config;
Мы настраиваем babel, создавая файл .babelrc
в корне нашего проекта
{
"presets": [
["@babel/preset-env",
{
"targets": {
"node": "current"
}
}]
]
}
Вы можете прочитать больше о пакетировании кода на этих ресурсах:
Что такое пакетирование с помощью webpack?
Демистификация пакетирования кода с помощью модулей JavaScript
Далее мы установим express, чтобы начать сборку нашего сервера
npm install express
Мы установим следующие пакеты в качестве промежуточного программного обеспечения express, чтобы придать нашему серверу расширенную функциональность
body-parser: Для разбора тела входящего запроса как json и urlencoded значений (также работает для FormData). Тело запроса будет доступно для наших обработчиков.
cookie-parser: Разбирает cookies в заголовках запроса и делает их доступными в объекте запроса для наших обработчиков.
шлем: Для добавления необходимых заголовков безопасности на наш сервер для повышения безопасности. Это необходимо для защиты от основных атак, таких как атаки межсайтового скриптинга. Сам по себе он не является достаточным, но полезен для обеспечения некоторого базового уровня безопасности
сжатие: Для сжатия тела ответа
cors: Для настройки политики разделения ресурсов (Cross Origin Resource Sharing), чтобы клиенты из разрешенных доменов могли взаимодействовать с нашим сервером. Хорошую статью о кросс-оригинальных запросах можно посмотреть здесь
npm install body-parser cookie-parser helmet compression cors
Затем мы можем создать наше экспресс-приложение в файле express.js
в каталоге сервера
import express from "express";
import bodyParser from "body-parser";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import compress from "compression";
import cors from "cors";
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(cookieParser());
app.use(helmet());
app.use(compress());
app.use(cors());
export default app;
Мы экспортируем наше экспресс-приложение для импорта в наш основной файл server.js
. Мы создаем файл config.js для хранения необходимых конфигураций, которые будут использоваться на нашем сервере, и экспортируем один объект config для импорта в server.js
.
export default {
env: process.env.NODE_ENV || 'development',
port: process.env.PORT || 4000
}
import mongoose from "mongoose";
import config from './config/config'
app.listen(config.port, (err) => {
if (err) {
console.error(err);
} else {
console.info(`Application running on PORT: ${config.port}`)
}
})
Когда наши серверные файлы настроены, мы можем запустить наш dev-сервер. Nodemon является полезным ресурсом для запуска вашего dev-сервера, поскольку он автоматически перезапускается (что-то вроде горячей перезагрузки), когда вы вносите изменения в исходный код.
npm install -D nodemon
Затем мы создаем конфигурационный файл nodemon
nodemon.json
и настраиваем его на запуск webpack
и нашего сгенерированного кода из директории dist
, которую мы указали в webpack.config.js
.
{
"verbose": false,
"watch": ["./server"],
"exec": "webpack --mode=development --config webpack.config.server.js && node ./dist/server.generated.js"
}
Наконец, мы установили dev скрипт в нашем package.json для запуска nodemon с вышеуказанным конфигом, чтобы настроить запуск нашего dev сервера
{
"name": "social-media-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon", //. <-- HERE
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.19.2",
"compression": "^1.7.4",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.17.3",
"helmet": "^5.0.2",
},
"devDependencies": {
"@babel/core": "^7.17.5",
"babel-loader": "^8.2.3",
"nodemon": "^2.0.15",
"webpack": "^5.69.1",
"webpack-cli": "^4.9.2",
"webpack-node-externals": "^3.0.0"
}
}
Теперь мы можем запустить скрипт dev для запуска нашего сервера
npm run dev
Вывод ниже показывает, что наш код был собран webpack
и сгенерированный файл в настоящее время выполняется, а сервер запущен.
Теперь мы можем приступить к реальной работе
Пользователи
Пользователи — это первый ресурс, с которым мы будем работать. Мы хотим иметь возможность создавать, читать, обновлять и удалять пользователей, а также иметь основу для базовой аутентификации.
Мы будем использовать Prisma в качестве объектно-реляционного сопоставителя для нашей базы данных MongoDB. Mongoose также является другим вариантом, но мне нравится безопасность типов Prisma при использовании typescript и декларации схем, поскольку это упрощает обслуживание.
Команда Prisma подготовила инструкцию по настройке Prisma с MongoDB, которую можно найти здесь:
Начать с нуля с MongoDB (15 мин)
Мы можем проделать все шаги для начала настройки до установки схемы. Ниже приведена схема для нашей модели пользователя
type UsersImage {
contentType String
data Bytes
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
v Int @map("__v") @default(0)
createdAt DateTime @default(now()) @db.Date
email String @unique
password String
image UsersImage?
name String
salt String
updatedAt DateTime @updatedAt @db.Date
about String?
@@map("users")
}
Мы определяем имя поля в первом столбце, тип поля в следующем и атрибуты и модификаторы поля в последнем столбце
- id: Первичный ключ для каждого пользовательского документа. Это будет строка, обозначаемая как первичный ключ с помощью
@id
. Модификатор@default(auto())
автоматически генерирует ID по умолчанию при создании документа. @map позволяет нам задать имя поля в базе данных. В MongoDB имя поляID
всегда_id
и должно быть отображено с помощью@map("_id")
. Когда мы создаем документы и просматриваем их в базе данных, мы будем видеть_id
как поле, а когда мы используем Prisma ORM в нашей разработке, мы сможем получить к нему доступ как кid
на объекте пользователя. Prisma позаботится о сопоставлении. @db.ObjectId — это родной атрибут типа базы данных, который позволяет нам указать тип данных для поля. ObjectIds — это тип поля, родной для документов MongoDB, подобно тому, как в базах данных SQL есть такие типы, как VARCHAR. - v: Это поле в документе предназначено для определения версии документа. В будущем, если мы начнем сохранять разные версии документов, но не захотим выбрасывать старые данные, это поле позволит нам различать, какую версию документов использовать. Подробнее о шаблоне версионирования документов можно прочитать здесь. Мы установили его версию по умолчанию равной 0
- createdAt и updatedAt: Мы храним даты создания и обновления документов.
- email: Мы храним электронную почту пользователя и обеспечиваем ее уникальность
- пароль: хэшированный пароль пользователя, который будет храниться
- изображение: На данный момент мы будем хранить данные изображения пользователя в базе данных как двоичные данные. Обратите внимание, что это не лучшая практика, и позже мы будем работать над переносом схемы для хранения ссылок на изображения, которые будут извлекаться из удаленного хранилища. Мы создаем поле
type
для определения формы нашего изображения. Мы будем хранить данные и тип содержимого (это тип MIME) - имя: Имя пользователя как строка
- about: Краткое описание пользователя
- salt (соль): Это часть случайных данных, используемых при шифровании пароля пользователя во время аутентификации. Имея разные значения соли для разных пользователей, мы повышаем безопасность наших паролей, так как это ограничивает возможность использования общих паролей. Страница Salting в Википедии — отличное место для ознакомления с преимуществами использования соли.
Полный справочник полей и модификаторов Prisma можно найти здесь.
После определения нашей модели мы можем приступить к установке клиента Prisma
npm install @prisma/client
Затем мы запускаем prisma generate
, который считывает нашу схему и генерирует версию клиента Prisma (с определениями типов и безопасностью), адаптированную к нашей схеме.
prisma generate
Чтобы включить использование prisma, нам нужно инициализировать prisma client, который позволит нам запрашивать нашу базу данных.
touch server/prisma/prisma.js
import {PrismaClient} from '@prisma/client'
const prisma = global.prisma || new PrismaClient()
if (process.env.NODE_ENV === 'development') {
if (!global.prisma) {
global.prisma = prisma
global.prisma.$connect().then(() => {
console.log('Database connected successfully')
});
}
} else {
prisma.$connect().then(() => {
console.log('Database connected successfully')
});
}
export default prisma;
Мы импортируем PrismaClient, подключаемся к базе данных и экспортируем объект prisma для использования в нашем приложении. Во время разработки обычно приходится перезапускать сервер много раз по мере внесения изменений, поэтому, чтобы избежать создания многочисленных соединений, мы прикрепляем объект prisma к глобальному объекту узла (известному как globalThis
). Мы проверяем, существует ли этот объект на глобальном объекте, когда приложение запускается, или создаем нового клиента в противном случае. Обратите внимание, что присвоение объекта global выполняется только в процессе разработки.
После этого мы можем приступить к запросам к базе данных.
Мы хотим сделать возможным создание, чтение, обновление и удаление пользователя в нашем приложении. Мы сделаем это в контроллере пользователя, используя клиентский API Prisma для запроса к базе данных.
touch server/controllers/user.controller.js
Давайте определим метод для создания и хранения пользователей в базе данных. Это будет полезно в процессе регистрации
import crypto from 'crypto';
import isEmail from 'validator/lib/isEmail';
import contains from 'validator/lib/contains';
import prisma from '../prisma/prisma';
import {ValidationError} from "../helpers/db.error.handler";
const encryptPassword = (password, salt) => {
try {
return crypto.createHmac('sha1', salt).update(password).digest('hex');
} catch (err) {
console.error(err);
}
}
const makeSalt = () => String(Math.round((new Date().valueOf() * Math.random())))
const emailIsValid = (email) => {
return isEmail(email)
}
const nameIsValid = (name) => {
return name.length > 1
}
const passwordIsValid = (password) => {
const errors = [];
if (!password) {
errors.push('Password is required')
}
if (password?.length < 8) {
errors.push('Password must be at least 8 characters')
}
if (password && contains(password, 'password')) {
errors.push('Password should not contain the word password')
}
if (errors.length > 0) {
return {
isValid: false,
message: errors.join(',')
}
} else {
return {
isValid: true
}
}
}
const store = async (req, res, next) => {
const {name, email, password} = req.body;
const userData = {name, email, password};
try {
const errors = {};
if (!nameIsValid(name)) {
errors.name = 'User name too short'
}
if (!emailIsValid(email)) {
errors.email = 'Invalid email'
}
const existingUser = await prisma.findUnique({
where: {
email
}
})
if (existingUser !== null) {
throw new ValidationError({
error: "User already exists"
})
}
const passwordValid = passwordIsValid(password)
if (!passwordValid.isValid) {
errors.password = passwordValid.message
}
if (Object.keys(errors).length > 0) {
throw new ValidationError(errors);
}
const salt = makeSalt();
const hashed_password = encryptPassword(password, salt);
await prisma.create({
data: {
name,
email,
password: hashed_password,
salt,
}
})
return res.status(200).json({
message: 'User saved successfully'
})
} catch (e) {
if (e instanceof ValidationError) {
return res.status(422).json(e.errors)
}
next(e);
}
};
export default { store }
Давайте разберем вышеописанное. Мы создали метод store
, который представляет собой промежуточное ПО Express для обработки создания пользователей при отправке запроса на этот маршрут. Он принимает в качестве параметра объект входящего запроса, а также объект для отправки ответа. Функция next
полезна, если мы собираемся передать ошибку обработчику ошибок или передать управление следующему промежуточному ПО. В данном случае мы будем использовать next для передачи любых неожиданных ошибок нашему обработчику ошибок. О том, как работает express, вы можете узнать из его документации.
Мы деструктурируем необходимые ключи из тела запроса и начинаем валидацию. Мы создаем отдельные функции для обработки валидации наших данных. Мы создаем объект error, который будет использоваться для хранения ошибок валидации, если данные запроса не пройдут нашу проверку.
nameIsValid
проверяет, что в имени пользователя больше одного символа.
emailIsValid
использует вспомогательный метод isEmail
из пакета validator для проверки того, что предоставленный email является допустимым форматом email.
Мы также проверяем, существует ли уже пользователь, и в этом случае сразу же выдаем ошибку
passwordIsValid
проверяет, что пароль присутствует, состоит как минимум из 8 символов и не содержит слова «password». Мы храним отдельные сообщения об ошибках в массиве, объединяем их и возвращаем в качестве свойства message объекта вывода, а также isValid
для обозначения того, является ли пароль действительным.
Если какая-либо из этих проверок не прошла, имена полей добавляются в объект ошибки. На основании количества ключей в объекте ошибки после валидации мы бросаем ValidationError с ошибками как часть конструктора. Мы создаем ValidationError, как показано ниже.
touch server/helpers/db.error.handler.js
export class ValidationError extends Error {
constructor(errors) {
super();
this.errors = errors;
}
}
Мы проверяем наличие этой ошибки в блоке catch
нашего try catch
и на основании этого отправляем код состояния 422 и объект ошибок в качестве ответа.
После проверки всех данных мы создаем соль с помощью makeSalt
, которая просто генерирует случайный набор чисел на основе текущей даты в миллисекундах. Мы используем ее для шифрования пароля пользователя с помощью пакета crypto
от node.
Наконец, мы используем клиент prisma для создания пользователя и отправляем ответ 200 с сообщением пользователю. Если возникает ошибка, которая не является ValidationError, мы передаем ее нашему обработчику ошибок пользователю next(e)
Мы рассмотрим этот обработчик ошибок в ближайшее время. Наконец, мы экспортируем метод store как часть экспорта объекта по умолчанию. Это позволит другим модулям иметь к нему доступ.
Маршрутизация
Теперь, когда у нас есть наш контроллер, который будет обрабатывать создание пользователей, нам нужно сделать возможным получение данных для создания пользователей. Для этого мы подключим наши контроллеры к маршрутам, которые будет использовать наш экспресс-сервер.
import express from 'express';
import userController from '../controllers/user.controller';
const userRouter = express.Router();
userRouter.route('/api/users')
.post(userController.store)
export default userRouter
Выше мы:
- Импортируем модуль
express
и экспортированный объектuserController
. - Создаем маршрутизатор
express
для обработки маршрутизации - Маршрутизируйте
POST
запросы к/api/users
и обрабатывайте их с помощью методаstore
, который мы создали - Экспортируйте
userRouter
.
Наш последний шаг — «прикрепить» наш userRouter к нашему серверу
import express from "express";
import bodyParser from "body-parser";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import compress from "compression";
import cors from "cors";
import userRouter from "./routes/user.routes"; //new
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: true}));
app.use(cookieParser());
app.use(helmet());
app.use(compress());
app.use(cors());
app.use(userRouter); //new
export default app;
Когда наш маршрутизатор подключен к серверу, мы можем запустить наш сервер, чтобы начать принимать запросы
npm run dev
Используя post man, мы делаем POST
запрос на наш сервер (работающий на порту 4000, помните) по маршруту /api/users
для создания пользователя.
Мы получаем успешный ответ от сервера
Мы можем проверить нашу базу данных, чтобы увидеть, что пользователь успешно создан
Мы успешно создали простое экспресс-приложение, создали контроллер для обработки логики и настроили маршруты для направления запросов к нашим контроллерам. В нашем контроллере мы поместили все методы непосредственно в обработчик, но полезно инкапсулировать логику в службу или вспомогательную функцию, которая изолирует обработчик от изменений, происходящих на более низком уровне абстракции. Таким образом, мы можем просто вызвать сервис для создания пользователя, и вся логика будет содержаться в нем, обеспечивая тем самым чистоту нашего контроллера и его работу на соответствующем уровне абстракции,
Листинг, удаление и обновление — это дополнительные функции, которые мы можем включить в наш сервер, создавая больше сервисов, контроллеров и маршрутов, а также отправляя соответствующие запросы к базе данных Mongo с помощью клиента Prisma. Документация Prisma полна подробностей о том, как это сделать, и вы можете ознакомиться с ними, чтобы расширить настройки и создать простой CRUD API