Облегченная альтернатива GraphQL, резольверы вместо конечных точек
1 марта 2023 г.Несколько месяцев назад я начал работать над личным проектом, связанным с взаимодействием клиент-сервер. Чтобы воспользоваться этим, я попытался использовать GraphQL. Хотя в целом мне нравился подход с одной конечной точкой API и распознавателями, накладные расходы на настройку и обслуживание сервера GraphQL были слишком велики для такой операции, выполняемой одним человеком, как моя.
Именно тогда мне пришла в голову идея заменить GraphQL более легким и простым в управлении подходом. Мое решение, которое я более подробно опишу ниже, помогло мне изменить правила игры и значительно повысило эффективность и скорость разработки.
В отличие от GraphQL, мое решение не требует накладных расходов на настройку и поддержку нескольких схем, которые необходимо установить для клиента и сервера. Вместо этого он определяет функции преобразователя, чтобы обеспечить гибкую альтернативу. В этой статье я подробно расскажу о своем решении и преимуществах, которые оно принесло моему любимому проекту."
Шаг 1. Создание приложения Basic Express
Для начала давайте определим базовое приложение ExpressJS, которое будет иметь одну конечную точку.
app.js
import express from 'express';
import apiRouter from "./api-route.js";
import cors from "cors";
const app = express();
app.use(express.json());
app.use('/api', apiRouter);
app.use('cors');
const port = process.env.PORT || 5000;
const server = app.listen(port, () => {
console.log(`Listening to port ${port}`);
});
и
api-route.js
import express from "express";
const router = express.Router();
router.get('*', async (req, res, next) => {
const response = {
text: 'Hello World!'
};
return res.json(response);
})
export default router;
Это самое простое экспресс-приложение, на каждый запрос GET к localhost:5000/api/* будет отправлено тело:
{
"text": "Hello World"
}
Вы можете найти этот код в следующем репозитории GitHub в ветке шаг-1: anatoly314/ шаблонная статья о сервере
Шаг 2. Лучший подход к разработке API: поставщик Resolvers
Чтобы добавить больше конечных точек в наше приложение, мы могли бы использовать традиционный подход, сгруппировав все конечные точки в файлы маршрутов и импортировав их одну за другой.
Однако я хотел бы предложить новый способ, который упрощает этот процесс.
Вместо этого мы можем создать папку api
и определить в ней новый файл с именем resolvers-provider.js
, который будет содержать следующее содержимое:
resolvers-provider.js
import glob from 'glob';
const __registeredResolvers = {};
export const registerResolver = (resolverName, resolver) => {
if (__registeredResolvers[resolverName]) {
throw new Error(`Resolver with ${resolverName} name already exists`);
}
__registeredResolvers[resolverName] = resolver;
}
export const getResolver = resolverName => {
const resolver = __registeredResolvers[resolverName];
if (!resolver) {
throw new Error(`Resolver with ${resolverName} wasn't registered`);
}
return resolver;
}
export const registerResolvers = async directoryName => {
const files = glob.sync(directoryName + '/**/*.js', {
absolute: true
});
const resolverFiles = files.filter(file => file.endsWith('-resolvers.js'));
for (const resolverFile of resolverFiles) {
const module = await import(resolverFile);
Object.keys(module).forEach(key => {
const resolver = module[key];
registerResolver(resolver);
})
}
}
Чтобы вызвать resolver-provider.js
, просто добавьте следующий код в файл api-route.js
:"
...
const __dirname = dirname(fileURLToPath(import.meta.url));
await registerResolvers(__dirname);
...
Когда вы вызываете registerResolver
, он будет искать все файлы, оканчивающиеся на -resolvers.js
, в каталоге api
и его подкаталогах.
Затем он зарегистрирует все экспортированные функции из этих файлов как преобразователи.
Затем добавьте новый файл с именем users-resolvers.js
в каталог api/users
и включите в него следующее содержимое:
users-resolvers.js
export const getUsersByName = ({body}) => {
const {name} = body;
const allUsers = [{
id: 1,
name: 'Anatoly',
age: 42
}, {
id: 2,
name: 'Yulia',
age: 34
}, {
id: 3,
name: 'John',
age: 55
}];
const filteredUsers = allUsers.filter(user => user.name === name);
return filteredUsers;
}
При запуске сервера все экспортированные функции из файлов, соответствующих шаблону api/*-resolvers.js
, будут автоматически зарегистрированы как преобразователи.
Чтобы использовать зарегистрированные распознаватели, нам нужно сделать последний шаг. Нам нужно заменить существующую конечную точку GET в api-route.js
следующей конечной точкой POST:
router.post('*', async (req, res, next) => {
try {
const body = req.body;
const authorizedUser = req.header('Authorization');
const resolverName = req.url.substring(1);
const resolver = getResolver(resolverName);
const result = await resolver({body, authorizedUser});
return res.json(result);
} catch (err) {
if (err.message === 'Unauthenticated') {
res.status(401);
res.send(err.message);
} else {
res.status(500);
res.send("Server error");
}
}
});
Чтобы вызвать наш преобразователь, мы можем отправить запрос POST к следующему шаблону URL:
localhost:5000/api/<RESOLVER_NAME>
.
Например, чтобы вызвать преобразователь getUsersByName
, мы должны отправить запрос на
localhost:5000/api/getUsersByName
. Тело запроса должно содержать
необходимые входные данные для обработки распознавателем.
{
"name": "Anatoly"
}
Когда наш сервер получит этот запрос, он вызовет преобразователь
с именем getUsersByName
, определенным в файле users-resolvers.js
.
находится в каталоге api/users
.
Содержимое тела будет передано преобразователю в качестве входных данных.
Итак, что мы получили от этого подхода? Несмотря на то, что вначале потребовались некоторые усилия для настройки поставщика распознавателей, преимущества значительны. Теперь, если нам нужно добавить еще одну конечную точку, вместо того, чтобы определять весь код конечной точки по-старому, мы можем просто добавить новую функцию к существующему поставщику преобразователей или создать новый поставщик преобразователей. Это приводит к меньшему количеству кода, меньшему количеству ошибок и меньшим затратам времени на разработку в целом.
Шаг 3. Защитите свои преобразователи с помощью декораторов JavaScript
С помощью шаблона, который мы создали, мы заменили традиционные конечные точки API ExpressJS подходом Resolvers, который упрощает нашу кодовую базу и значительно облегчает жизнь разработчикам.
Однако в этом подходе отсутствует поддержка промежуточного программного обеспечения, а это означает, что нам нужно найти альтернативный способ выполнения таких задач, как аутентификация и авторизация, которые обычно выполняются с помощью промежуточного программного обеспечения.
Один из вариантов — использовать декораторы JavaScript. Мы создадим декоратор JavaScript, требующий аутентификации для запросов к распознавателю. Одной из возможных реализаций является проверка того, что заголовок запроса Authentication
содержит слово admin
.
Декораторы JavaScript считаются безопасными для использования, поскольку они находятся на этапе 3
Этап 3 – этап «Кандидат». На данном этапе предложение было принято TC39 в качестве потенциального дополнения к языку и одобрено для включения в будущую версию ECMAScript. Предложение считается в основном завершенным, оно было рассмотрено и уточнено членами TC39, но в него могут быть внесены некоторые изменения на основе отзывов реализаторов и разработчиков. Предложения на этапе 3 обычно считаются безопасными для использования и доступны в некоторых экспериментальных средах выполнения JavaScript, но официально они еще не являются частью языка.
* Подробнее см. здесь: tc39/proposal-decorators
Поскольку декораторы JavaScript по-прежнему считаются экспериментальной функцией, а Node.js в настоящее время не имеет встроенной поддержки для них, нам нужно будет использовать Babel для переноса нашего кода. Для этого мы создадим файл .babelrc
в корне нашего проекта, который позволит нам настроить Babel и использовать его возможности транспиляции:
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
]
],
"env": {
"development": {
"sourceMaps": "inline",
"retainLines": true
}
},
"plugins": [
"@babel/plugin-syntax-top-level-await",
["@babel/plugin-proposal-decorators", {
"version": "2022-03"
}]
]
}
Чтобы внести необходимые изменения, сначала следует включить приведенный ниже блок кода в раздел сценария файла package.json
:
{
"build": "babel src -d dist",
"start": "npm run build && node dist/app.js",
"start-nodemon": "nodemon --watch './src/**/*.js' --exec npm run start"
}
Затем выполните следующую команду, чтобы установить необходимые devDependencies
:
npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/plugin-proposal-decorators
После этого вы можете запустить приложение, запустив npm run start-nodemon
.
Babel перенесет код из папки src
в новую папку dist
, выполнит его и автоматически перекомпилирует при любых изменениях, внесенных в проект. Прежде чем мы сможем начать использовать декораторы JavaScript, нам нужно внести некоторые изменения в наш код. В частности, необходимо изменить следующие файлы: src/api/users/users-resolvers.js
и src/api/resolvers-provider.js
Согласно предложению, декораторы можно использовать с классами и их элементами, такими как поля, методы и средства доступа. Чтобы использовать эту функцию, нам нужно убедиться, что наш поставщик распознавателей является экземпляром класса. Поэтому мы изменим код в src/api/users/users-resolvers.js
к следующему:
class UsersResolvers {
getUsersByName ({body}) {
const {name} = body;
const allUsers = [{
id: 1,
name: 'Anatoly',
age: 42
}, {
id: 2,
name: 'Yulia',
age: 34
}, {
id: 3,
name: 'John',
age: 55
}];
const filteredUsers = allUsers.filter(user => user.name === name);
return filteredUsers;
}
}
export default UsersResolvers;
Нам также необходимо внести изменения в наш src/api/resolvers-provider.js
, чтобы он мог создавать экземпляры класса и получать ссылки на его методы класса:
...
for (const resolverFile of resolverFiles) {
const module = await import(resolverFile);
const instantiatedResolver = new module.default();
Object.getOwnPropertyNames(module.default.prototype).forEach(key => {
if (key !== 'constructor') {
const resolver = instantiatedResolver[key];
registerResolver(key, resolver.bind(instantiatedResolver.self));
}
})
}
...
Теперь, когда все приготовления завершены, мы можем приступить к написанию нашего первого декоратора JavaScript. Давайте создадим новую папку с именем src/decorators
и добавим в нее файл
называется auth-decorator.js
:
auth-decorator.js
export function authRequired(userType){
return function (value, { kind, name }) {
if (kind === "method" || kind === "getter" || kind === "setter") {
return function (...args) {
const {authorizedUser} = args[0];
if (authorizedUser !== userType) {
throw new Error("Unauthenticated");
}
const ret = value.call(this, ...args);
return ret;
};
}
}
}
И мы можем применить его к методу getUsersByName
в users-resolvers.js
следующим образом:
...
@authRequired("admin")
getUsersByName ({body}) {
...
}
...
При добавлении декоратора authRequired
в функцию преобразователя, например getUsersByName
в users-resolvers.js
, запросы к этому преобразователю потребуют аутентификация. Это означает, что запрос должен содержать значение admin
в заголовке Authentication
, иначе запрос будет
отклонено с ошибкой 401
, указывающей на отсутствие аутентификации.
Это всего лишь базовый пример того, что можно сделать с помощью декораторов. В целом все, чего можно достичь с помощью промежуточного программного обеспечения, можно достичь с помощью декораторов, что обеспечивает большую гибкость в способах обработки запросов в приложении.
Шаг 4. Создание хука React, который будет беспрепятственно вызывать резолверы
Мы реализовали подход на стороне сервера, который заменяет обычный API конечными точками подходом с распознавателями, давайте создадим хук ReactJS, который будет беспрепятственно вызывать эти распознаватели:
import axios from "axios";
import {useState} from "react";
const RESOLVER_API_URL = "http://localhost:4000/api";
export const useResolver = (resolverName) => {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(null);
const invokeResolver = async data => {
setLoading(true);
try {
const url = `${RESOLVER_API_URL}/${resolverName}`;
const config = {
url,
headers: {
Authorization: 'admin'
},
method: 'POST',
data: data
}
const response = await axios(config);
setResult(response.data);
return response.data;
} catch (err) {
const errText = `Error invoking resolver ${resolverName}`;
console.log(errText, err);
throw new Error(errText);
} finally {
setLoading(false);
}
}
return [invokeResolver, result, loading];
}
Вызов любого преобразователя теперь может быть таким же простым, как следующее:
const [getUsersByName, users, loadingUsers] = useResolver("getUsersByName");
Функция getUsersByName
теперь может вызываться с использованием простого синтаксиса.
Результаты вызова сохраняются в свойстве users
, а логический флаг loadingUsers
указывает текущее состояние вызова.
Вот полный пример компонента React, использующего этот хук:
import React, {useState} from "react";
import {useResolver} from "../../resolvers/resolvers-hook";
const Users = () => {
const [getUsersByName, users, loadingUsers] = useResolver("getUsersByName");
const [inputValue, setInputValue] = useState('');
const handleInputChange = (event) => {
setInputValue(event.target.value);
}
return (
<div>
{
loadingUsers && <div>LOADING</div>
}
Users with the name:
<input
type="text"
value={inputValue}
onChange={handleInputChange}
/>
<button onClick={() => getUsersByName({
name: inputValue
})}>Load Users</button>
<pre>
{JSON.stringify(users, null, 2)}
</pre>
</div>
)
}
export default Users;
Кредиты:
- ChatGPT, я хотел бы выразить свою благодарность ChatGPT за невероятную помощь, оказанную мне. Чат-бот помог мне решить различные вопросы и задачи и улучшить мои навыки письма на английском языке. Приведенные примеры и пояснения были неоценимы для понимания сложных концепций. Я хотел бы поблагодарить всю команду ChatGPT за создание такого замечательного ресурса.
- Сообщество разработчиков GraphQL, я хотел бы отметить вклад сообщества разработчиков GraphQL, включая Facebook и Apollo. Многое из того, что я сделал в этом проекте, было вдохновлено их кодовой базой."
Оригинал