Битва Koa2 + MongoDB + JWT — лучшая практика Restful API

внешний интерфейс koa
Битва Koa2 + MongoDB + JWT — лучшая практика Restful API

введение

Web APIЧистый дизайн API, ставший важной темой в последние годы, очень важен для серверных систем.

Обычно мы используем для веб-APIRESTfulдизайн,RESTконцепция разделенаAPI 结构а также逻辑资源, через HTTP-методGET, DELETE, POSTа такжеPUTи т.д. для работы ресурса.

Эта статья основана на моем недавнем проекте, основанном наkoa+mongodb+jwtПозвольте мне рассказать вам о лучших практиках для RESTful API.

Что такое RESTful API?

Узнать большеRESTful APIПрежде давайте посмотрим, чтоREST.

RESTПолное имяRepresentational state transfer. детали следующим образом:

  • Репрезентативная: репрезентативная форма данных (JSON, XML...)
  • состояние: текущее состояние или данные
  • передача: передача данных

Он описывает, как одна система взаимодействует с другой. Например, статус (название, подробности) продукта представлен в виде XML, JSON или обычного текста.

REST имеет шесть ограничений:

  • Клиент-сервер

    Разделение забот. Сервер фокусируется на хранении данных, что повышает простоту, а внешний интерфейс фокусируется на пользовательском интерфейсе, что повышает переносимость.

  • без гражданства

    Вся информация о сеансе пользователя хранится на стороне клиента. Каждый запрос должен включать всю информацию и не может полагаться на контекстную информацию. Серверу не нужно сохранять информацию о сеансе, что повышает простоту, надежность и наглядность.

  • Кэш

    Все ответы на стороне сервера помечаются как кэшируемые или некэшируемые, что снижает количество взаимодействий с интерфейсом и сервером и повышает производительность.

  • Единый интерфейс

    Дизайн интерфейса максимально единообразен и универсален, повышая простоту и наглядность. Интерфейс не связан с реализацией, так что передняя и задняя части могут разрабатываться и повторяться независимо друг от друга.

  • Многоуровневая система

  • Код по требованию

Прочитав шесть ограничений REST, давайте взглянем на отраслевыеRESTful APIКраткий обзор лучших практик дизайна.

Лучшие практики

请求设计规范

  • URI 使用名词, попробуйте использовать множественное число, например /users
  • Использование URI嵌套выражать关联关系, например /users/123/repos/234
  • использовать正确的 HTTP 方法, например GET/POST/PUT/DELETE

响应设计规范

  • 查询
  • 分页
  • 字段过滤

Если количество записей велико, сервер не может вернуть их все пользователю. API должен предоставлять параметры для фильтрации возвращаемых результатов. Вот некоторые общие параметры (включая приведенный выше запрос, разбивку на страницы и фильтрацию полей):

?limit=10:指定返回记录的数量
?offset=10:指定返回记录的开始位置。
?page=2&per_page=100:指定第几页,以及每页的记录数。
?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
?animal_type_id=1:指定筛选条件
  • 状态码
  • 错误处理

Точно так же, как HTML-страницы с ошибками предоставляют посетителям полезные сообщения об ошибках, API-интерфейсы должны предоставлять полезные сообщения об ошибках в ранее разборчивом формате.

Например, для общей формы отправки при появлении следующего сообщения об ошибке:

{
    "error": "Invalid payoad.",
    "detail": {
        "surname": "This field is required."
    }
}

Вызывающие интерфейс могут быстро найти причину ошибки.

安全

  • HTTPS
  • 鉴权

RESTful API должны быть без состояния. Это означает, что аутентификация запросов не должна основываться наcookieилиsession. Вместо этого каждый запрос должен сопровождаться некоторыми учетными данными для аутентификации.

  • 限流

Чтобы избежать флуда запросов, установите API速度限制Очень важный. с этой цельюRFC 6585Введены коды состояния HTTP429(too many requests). После добавления параметра скорости пользователю должно быть предложено.

Так много было сказано выше, давайте посмотрим, как практиковать в Коа.RESTful API最佳实践Бар.

Внедрение RESTful API в Koa

Давайте посмотрим на готовую структуру каталогов проекта:

|-- rest_node_api
    |-- .gitignore
    |-- README.md
    |-- package-lock.json
    |-- package.json      # 项目依赖
    |-- app
        |-- config.js     # 数据库(mongodb)配置信息
        |-- index.js      # 入口
        |-- controllers   # 控制器:用于解析用户输入,处理后返回相应的结果
        |-- models        # 模型(schema): 用于定义数据模型
        |-- public        # 静态资源
        |-- routes        # 路由

Каталог проекта представляет собой четкую иерархическую и подмодульную структуру, что также удобно для последующего обслуживания и расширения. Ниже мы объясним моменты, на которые необходимо обратить внимание в проекте.

Контроллер

什么是控制器?

  • Получить задание, назначенное маршрутом, и выполнить его
  • В koa это промежуточное ПО

为什么要用控制器

  • 获取 HTTP 请求参数

    • Строка запроса, например ?q=keyword
    • Параметры маршрутизатора, такие как /users/:id
    • Тело, например {name: 'jack'}
    • Заголовок, например Accept, Cookie
  • 处理业务逻辑

  • 发送 HTTP 响应

    • Отправить статус как 200/400
    • Отправить тело, например {name: 'jack'}
    • Отправить заголовок, например Разрешить, Content-Type

编写控制器的最佳实践

  • Контроллер для каждого ресурса помещается в отдельный файл
  • Попробуйте использовать форму класса + метод класса для написания контроллера
  • Строгая обработка ошибок

示例

app/controllers/users.js

const User = require("../models/users");
class UserController {
  async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
}

module.exports = new UserController();

механизм обработки ошибок

koa自带错误处理

Для выполнения пользовательской логики обработки ошибок, такой как централизованное ведение журнала, вы можете добавить прослушиватель событий «ошибка»:

app.on('error', err => {
  log.error('server error', err)
});

中间件

используется в этом проектеkoa-json-errorДля обработки ошибок подробное введение в это промежуточное программное обеспечение будет расширено ниже.

Аутентификация и авторизация пользователей

Существует два широко используемых метода аутентификации и авторизации пользовательской информации:JWTа такжеSession. Давайте сравним преимущества и недостатки двух методов аутентификации.

Session

  • 相关的概念介绍

    • session:: В основном хранится на сервере, относительно безопасно
    • cookie: В основном хранится на стороне клиента и не очень безопасен.
    • sessionStorage: Действительно только для текущего сеанса, сбрасывается после закрытия страницы или браузера.
    • localstorage: сохранить навсегда, если не очищено
  • 工作原理

    • Клиент получает доступ к интерфейсу /login с именем пользователя и паролем.Сервер проверяет имя пользователя и пароль после их получения.Если проверка верна, отношение сопоставления между sessionId и session будет сохранено на сервере.
    • Сервер возвращает ответ и устанавливает идентификатор сеанса на клиенте в форме set-cookie, так что идентификатор сеанса существует на клиенте.
    • Когда клиент инициирует запрос без входа в систему, если сервер предоставляет set-cookie, браузер автоматически добавит файл cookie в заголовок запроса.
    • Сервер получает запрос, разбирает файл cookie, проверяет информацию и возвращает ответ клиенту после успешной проверки.
  • 优势

    • По сравнению с JWT самым большим преимуществом является то, что вы можете активно очищать сеанс.
    • Сессия хранится на стороне сервера, что относительно безопасно.
    • В сочетании с файлами cookie он более гибкий и имеет лучшую совместимость (и клиент, и сервер могут быть очищены или зашифрованы).
  • 劣势

    • cookie+session плохо работает в междоменных сценариях (не может быть междоменным, переменным доменом, требует сложной обработки междоменного взаимодействия)
    • Если это распределенное развертывание, вам необходимо использовать механизм общего сеанса с несколькими машинами (увеличение стоимости).
    • Механизмы на основе файлов cookie легко уязвимы для CSRF.
    • Запрос информации о сеансе может иметь операции запроса базы данных

JWT

  • 相关的概念介绍

    Поскольку подробное введение в JWT займет много места в статье, оно не является целью этой статьи. Итак, вот лишь краткое введение. В основном для сравнения с методом Session. Подробное введение в JWT см.https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html

Принцип JWT заключается в том, что после аутентификации сервера объект JSON генерируется и отправляется обратно пользователю следующим образом:

{
  "姓名": "森林",
  "角色": "搬砖工",
  "到期时间": "2020年1月198日16点32分"
}

В дальнейшем, когда пользователь будет общаться с сервером, этот объект JSON должен быть отправлен обратно. Сервер полагается исключительно на этот объект для аутентификации пользователя. Чтобы пользователи не могли подделывать данные, сервер добавит подпись при создании этого объекта.

Сервер не сохраняет никаких данных сеанса, то есть сервер становится апатридом, что упрощает реализацию расширения.

Формат JWT примерно следующий:

Это длинная строка, разделенная на три части точкой (.).

Три части JWT следующие:

Header(头部)
Payload(负载)
Signature(签名)
  • JWT相比Session
    • Безопасность (оба ошибочны)
    • RESTful API, JWT побеждает, потому что RESTful API выступает за безгражданство, JWT соответствует требованиям
    • Производительность (у каждого есть свои преимущества и недостатки, потому что информация JWT сильна, поэтому объем также больше. Однако сервер должен каждый раз искать сессию, а информация JWT сохраняется, поэтому нет необходимости запросить базу данных)
    • Своевременность, сессия может быть уничтожена непосредственно с сервера, а JWT может быть уничтожен только по истечении срока (изменение пароля не может помешать узурпатору использовать его)

jsonwebtoken

Поскольку RESTful API поддерживает безгражданство, а JWT соответствует этому требованию, мы принимаемJWTДля реализации авторизации и аутентификации пользовательской информации.

Проект принимает более популярныйjsonwebtoken. Для конкретного использования см.https://www.npmjs.com/package/jsonwebtoken

настоящий бой

Инициализировать проект

mkdir rest_node_api  # 创建文件目录
cd rest_node_api  # 定位到当前文件目录
npm init  # 初始化,得到`package.json`文件
npm i koa -S  # 安装koa
npm i koa-router -S  # 安装koa-router

После того, как основные зависимости установлены, вы можете сделать сначала одинhello-world

app/index.js

const Koa = require("koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

router.get("/", async function (ctx) {
    ctx.body = {message: "Hello World!"}
});

app.use(router.routes()).use(router.allowedMethods());

app.listen(3000);

Связанное промежуточное ПО и зависимости плагинов

koa-body

использовался раньшеkoa2при обработкеpostЗапрос используетkoa-bodyparser, а если изображение загружается с помощьюkoa-multer. Их комбинация хороша, но существует несовместимость между koa-multer и koa-route (не koa-router).

koa-bodyСочетает в себе то и другое, так что коа-тело может заменить его.

Установка зависимостей

npm i koa-body -S

app/index.js

const koaBody = require('koa-body');
const app = new koa();
app.use(koaBody({
  multipart:true, // 支持文件上传
  encoding:'gzip',
  formidable:{
    uploadDir:path.join(__dirname,'public/uploads'), // 设置文件上传目录
    keepExtensions: true,    // 保持文件的后缀
    maxFieldsSize:2 * 1024 * 1024, // 文件上传大小
    onFileBegin:(name,file) => { // 文件上传前的设置
      // console.log(`name: ${name}`);
      // console.log(file);
    },
  }
}));

Конфигурация параметров:

  • 基本参数

    имя параметра описывать Типы По умолчанию
    patchNode Введите тело запроса в собственный node.jsctx.reqсередина Boolean false
    patchKoa Введите тело запроса в koactx.requestсередина Boolean true
    jsonLimit Ограничение размера тела данных JSON String / Integer 1mb
    formLimit Ограничить размер тела запроса формы String / Integer 24kb
    textLimit Ограничить размер основного текста String / Integer 23kb
    encoding Кодировка по умолчанию для форм String utf-8
    multipart служба поддержкиmultipart-formdateформа Boolean false
    urlencoded служба поддержкиurlencodedформа Boolean true
    formidable настроить больше оmultipartОпции Object {}
    onError обработка ошибок Function function(){}
    stict Строгий режим, не будет анализироваться при включенииGET, HEAD, DELETEпросить Boolean true
  • formidable 的相关配置参数

    имя параметра описывать Типы По умолчанию
    maxFields Ограничьте количество полей Integer 500
    maxFieldsSize Ограничить максимальный размер поля Integer 1 * 1024 * 1024
    uploadDir папка для загрузки файлов String os.tmpDir()
    keepExtensions Сохранить исходный суффикс файла Boolean false
    hash Если вы хотите вычислить хэш файла, вы можете выбратьmd5/sha1 String false
    multipart Поддерживать ли загрузку нескольких файлов Boolean true
    onFileBegin Некоторые операции настройки перед загрузкой файла Function function(name,file){}

koa-json-error

При написании интерфейса возвращайтеjsonНеобходимы отформатированные и читаемые сообщения об ошибках,koa-json-errorПромежуточное ПО делает это за нас.

Установка зависимостей

npm i koa-json-error -S

app/index.js

const error = require("koa-json-error");
const app = new Koa();
app.use(
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);

Ошибки будут выбрасывать информацию о стеке по умолчаниюstack, в производственной среде нет необходимости возвращать его пользователю, его можно отобразить в среде разработки.

koa-parameter

использоватьkoa-parameterДля проверки параметров он основан на структуре проверки параметров.parameter, адаптация для фреймворка koa.

Установка зависимостей

npm i koa-parameter -S

использовать

// app/index.js
const parameter = require("koa-parameter");
app.use(parameter(app));

// app/controllers/users.js
 async create(ctx) {
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    ...
  }

потому чтоkoa-parameterосновывается наparameter的, это всего лишь слой инкапсуляции, базовая логика по-прежнему основана на параметрах, и пользовательские правила могут быть написаны со ссылкой на официальные описания и примеры параметров.

let TYPE_MAP = Parameter.TYPE_MAP = {
  number: checkNumber,
  int: checkInt,
  integer: checkInt,
  string: checkString,
  id: checkId,
  date: checkDate,
  dateTime: checkDateTime,
  datetime: checkDateTime,
  boolean: checkBoolean,
  bool: checkBoolean,
  array: checkArray,
  object: checkObject,
  enum: checkEnum,
  email: checkEmail,
  password: checkPassword,
  url: checkUrl,
};

koa-static

Если сайт предоставляет статические ресурсы (изображения, шрифты, стили, скрипты...), очень хлопотно и ненужно прописывать маршруты для них один за другим.koa-staticМодули инкапсулируют эту часть запроса.

app/index.js

const Koa = require("koa");
const koaStatic = require("koa-static");
const app = new Koa();
app.use(koaStatic(path.join(__dirname, "public")));

Подключиться к базе данных

База данных, которую мы используем,mongodb, Прежде чем подключаться к базе данных, давайте посмотримmongoose.

mongooseдаnodeJSобеспечить подключениеmongodbбиблиотека похожа наjqueryа такжеjsотношения, даmongodbНекоторые нативные методы инкапсулированы и оптимизированы. Проще говоря,Mongooseвот такnodeОкружающая обстановкаMongoDBИнкапсуляция операций базы данных, объектная модель (ODM) инструмент, который преобразует данные в базе данных вJavaScriptобъект для использования в нашем приложении.

установить мангуста

npm install mongoose -S

Подключить и настроить

const mongoose = require("mongoose");
mongoose.connect(
  connectionStr,  // 数据库地址
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

CRUD пользователя

Модулей в проекте много, и я не буду демонстрировать их по одному, потому что содержательное наполнение каждого модуля схоже. Здесь в основном на основе пользовательского модуляcrudВ качестве примера, чтобы показать, как практиковать в коаRESTful API最佳实践.

app/index.js(вход коа)

Файл входа в основном используется для создания сервисов koa, загрузки промежуточного программного обеспечения (middleware), регистрации маршрута (передается в модуль маршрутов), подключения к базе данных и т. д.

const Koa = require("koa");
const path = require("path");
const koaBody = require("koa-body");
const koaStatic = require("koa-static");
const parameter = require("koa-parameter");
const error = require("koa-json-error");
const mongoose = require("mongoose");
const routing = require("./routes");
const app = new Koa();
const { connectionStr } = require("./config");
mongoose.connect(  // 连接mongodb
  connectionStr,
  { useUnifiedTopology: true, useNewUrlParser: true },
  () => console.log("mongodb 连接成功了!")
);
mongoose.connection.on("error", console.error);

app.use(koaStatic(path.join(__dirname, "public")));  // 静态资源
app.use(  // 错误处理
  error({
    postFormat: (e, { stack, ...rest }) =>
      process.env.NODE_ENV === "production" ? rest : { stack, ...rest }
  })
);
app.use(  // 处理post请求和图片上传
  koaBody({
    multipart: true,
    formidable: {
      uploadDir: path.join(__dirname, "/public/uploads"),
      keepExtensions: true
    }
  })
);
app.use(parameter(app));  // 参数校验
routing(app);  // 路由处理

app.listen(3000, () => console.log("程序启动在3000端口了"));

app/routes/index.js

Так как модулей проекта много, то и соответствующих маршрутов тоже много. Если вы идете регистрироваться по одному, это слишком хлопотно. Здесь модуль fs узла используется для обхода и чтения всех файлов маршрутизации по маршрутам и их единообразной регистрации.

const fs = require("fs");

module.exports = app => {
  fs.readdirSync(__dirname).forEach(file => {
    if (file === "index.js") {
      return;
    }
    const route = require(`./${file}`);
    app.use(route.routes()).use(route.allowedMethods());
  });
};

app/routes/users.js

Маршрутизация пользовательского модуля, которая в основном включает в себя вход пользователя в систему, а также добавления, удаления и изменения.

const jsonwebtoken = require("jsonwebtoken");
const jwt = require("koa-jwt");
const { secret } = require("../config");
const Router = require("koa-router");
const router = new Router({ prefix: "/users" });  // 路由前缀
const {
  find,
  findById,
  create,
  checkOwner,
  update,
  delete: del,
  login,
} = require("../controllers/users");  // 控制器方法

const auth = jwt({ secret });  // jwt鉴权

router.get("/", find);  // 获取用户列表

router.post("/", auth, create);  // 创建用户(需要jwt认证)

router.get("/:id", findById);  // 获取特定用户

router.patch("/:id", auth, checkOwner, update);  // 更新用户信息(需要jwt认证和验证操作用户身份)

router.delete("/:id", auth, checkOwner, del);  // 删除用户(需要jwt认证和验证操作用户身份)

router.post("/login", login);  // 用户登录

module.exports = router;

app/models/users.js

Модель данных пользователя (схема)

const mongoose = require("mongoose");

const { Schema, model } = mongoose;

const userSchema = new Schema(
  {
    __v: { type: Number, select: false },
    name: { type: String, required: true },  // 用户名
    password: { type: String, required: true, select: false },  // 密码
    avatar_url: { type: String },  // 头像
    gender: {  //   性别
      type: String,
      enum: ["male", "female"],
      default: "male",
      required: true
    },
    headline: { type: String },  // 座右铭
    locations: {  // 居住地
      type: [{ type: Schema.Types.ObjectId, ref: "Topic" }],
      select: false
    },
    business: { type: Schema.Types.ObjectId, ref: "Topic", select: false },  // 职业
  },
  { timestamps: true }
);

module.exports = model("User", userSchema);

app/controllers/users.js

Контроллер пользовательского модуля для обработки бизнес-логики

const User = require("../models/users");
const jsonwebtoken = require("jsonwebtoken");
const { secret } = require("../config");
class UserController {
  async find(ctx) {  // 查询用户列表(分页)
    const { per_page = 10 } = ctx.query;
    const page = Math.max(ctx.query.page * 1, 1) - 1;
    const perPage = Math.max(per_page * 1, 1);
    ctx.body = await User.find({ name: new RegExp(ctx.query.q) })
      .limit(perPage)
      .skip(page * perPage);
  }
  async findById(ctx) {  // 根据id查询特定用户
    const { fields } = ctx.query;
    const selectFields =  // 查询条件
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => " +" + f)
        .join("");
    const populateStr =  // 展示字段
      fields &&
      fields
        .split(";")
        .filter(f => f)
        .map(f => {
          if (f === "employments") {
            return "employments.company employments.job";
          }
          if (f === "educations") {
            return "educations.school educations.major";
          }
          return f;
        })
        .join(" ");
    const user = await User.findById(ctx.params.id)
      .select(selectFields)
      .populate(populateStr);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async create(ctx) {  // 创建用户
    ctx.verifyParams({  // 入参格式校验
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const { name } = ctx.request.body;
    const repeatedUser = await User.findOne({ name });
    if (repeatedUser) {  // 校验用户名是否已存在
      ctx.throw(409, "用户名已存在");
    }
    const user = await new User(ctx.request.body).save();
    ctx.body = user;
  }
  async checkOwner(ctx, next) {  // 判断用户身份合法性
    if (ctx.params.id !== ctx.state.user._id) {
      ctx.throw(403, "没有权限");
    }
    await next();
  }
  async update(ctx) {  // 更新用户信息
    ctx.verifyParams({
      name: { type: "string", required: false },
      password: { type: "string", required: false },
      avatar_url: { type: "string", required: false },
      gender: { type: "string", required: false },
      headline: { type: "string", required: false },
      locations: { type: "array", itemType: "string", required: false },
      business: { type: "string", required: false },
    });
    const user = await User.findByIdAndUpdate(ctx.params.id, ctx.request.body);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.body = user;
  }
  async delete(ctx) {  // 删除用户
    const user = await User.findByIdAndRemove(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    ctx.status = 204;
  }
  async login(ctx) {  // 登录
    ctx.verifyParams({
      name: { type: "string", required: true },
      password: { type: "string", required: true }
    });
    const user = await User.findOne(ctx.request.body);
    if (!user) {
      ctx.throw(401, "用户名或密码不正确");
    }
    const { _id, name } = user;
    const token = jsonwebtoken.sign({ _id, name }, secret, { expiresIn: "1d" });  // 登录成功返回jwt加密后的token信息
    ctx.body = { token };
  }
  async checkUserExist(ctx, next) {  // 查询用户是否存在
    const user = await User.findById(ctx.params.id);
    if (!user) {
      ctx.throw(404, "用户不存在");
    }
    await next();
  }

}

module.exports = new UserController();

postman演示

登录

获取用户列表

获取特定用户

创建用户

更新用户信息

删除用户

наконец

На этом содержание этой статьи закончилось, здесь в основном нужно рассказать вам о пользовательском модуле.RESTful API最佳实践Использование в проекте koa. Исходный код проекта находится в открытом доступе, а адресhttps://github.com/Jack-cool/rest_node_api. Возьмите то, что вам нужно, если вы чувствуете себя хорошо, пожалуйста, дайте звезду!

В то же время, вы можете обратить внимание на мой одноименный паблик [Front-end Forest], где я буду регулярно публиковать несколько актуальных статей, связанных с большим фронт-эндом, и практические итоги в ежедневном процессе разработки.