Как домен-функции улучшают (и без того потрясающие) DX проектов Remix?

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

В этом посте я расскажу о некоторых из них и покажу, как мы решаем эти проблемы с помощью нашей новой библиотеки: domain-functions.

Водопровод

Как только любой проект начинает становиться немного серьезным, появляется потребность в структурированной архитектуре. Почти в каждом примере экшенов и загрузчиков Remix мы можем увидеть некоторые шаблоны, которые мы называем «сантехникой».

Например, посмотрите на действие в этом фрагменте кода приложения Jokes. Многое происходит! И обычно так выглядят приложения Remix Apps, передающие много ответственности контроллерам.

Давайте рассмотрим обязанности общего загрузчика/экшена в перспективе:

  • A) Извлечение данных из запроса, будь то строка запроса, тело, url, заголовки запроса и т.д.
  • B) Убедиться, что эти данные находятся в правильном формате, возможно, после этого выполнить некоторую обработку данных.
  • C) Выполнение побочных эффектов, таких как вставка данных в базу данных, отправка электронной почты и т.д.
  • D) Возвращение другого Response в зависимости от результата предыдущих шагов.
  • E) Также необходимо вручную поддерживать типы ваших Ответов в соответствии с тем, что ожидает пользовательский интерфейс, подробнее об этом мы поговорим позже в этом посте.

Как вы уже, наверное, догадались, мы думаем о загрузчиках и действиях как о контроллерах. И мы считаем, что контроллеры должны «говорить только по HTTP». При таком мышлении мы бы рефакторизовали приведенный выше список только до шагов A и D. Шаги B и C — это то, что мы называем бизнес-логикой, код, который делает проект уникальным.

И в Seasoned нам нравится разделять этот код на четко определенные/тестированные/типизированные домены.

Так как же нам разделить эту бизнес-логику с доменными функциями?

Во-первых, мы напишем схемы Zod для пользовательского ввода и данных окружения:

// app/domains/jokes.server.ts
const jokeSchema = z.object({
  name: z.string().min(2, `That joke's name is too short`),
  content: z.string().min(10, 'That joke is too short'),
})

const userIdSchema = z.string().nonempty()
Вход в полноэкранный режим Выход из полноэкранного режима

Затем мы напишем бизнес-логику, используя эти схемы:

// app/domains/jokes.server.ts
import { makeDomainFunction } from 'domain-functions'
// ...

const createJoke = makeDomainFunction(jokeSchema, userIdSchema)
  ((fields, jokesterId) =>
    db.joke.create({ data: { ...fields, jokesterId } })
  )
Вход в полноэкранный режим Выход из полноэкранного режима

И, наконец, мы напишем код контроллера:

// app/routes/jokes/new.tsx
import { inputFromForm } from 'domain-functions'
import type { ErrorResult } from 'domain-functions'
// ...

export const action: ActionFunction = async ({ request }) => {
  const result = await createJoke(
    await inputFromForm(request),
    await getUserId(request),
  )
  if (!result.success) {
    return json<ErrorResult>(result, 400)
  }
  return redirect(`/jokes/${result.data.id}?redirectTo=/jokes/new`)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь давайте перепишем этот загрузчик. Начнем с домена:

// app/domains/jokes.server.ts
const getRandomJoke = makeDomainFunction(z.null(), userIdSchema)
  (async (_i, jokesterId) => {
    const count = await db.joke.count()
    const skip = Math.floor(Math.random() * count)
    return db.joke.findMany({ take: 1, skip, where: { jokesterId } })
  })
Войти в полноэкранный режим Выйти из полноэкранного режима

Затем с загрузчика:

// app/routes/jokes/index.tsx
import type { UnpackData } from 'domain-functions'
// ...

type LoaderData = UnpackData<typeof getRandomJoke>
export const loader: LoaderFunction = async ({ request }) => {
  const result = await getRandomJoke(
    null,
    await getUserId(request)
  )
  if (!result.success) {
    throw new Response('No jokes to be found!', { status: 404 })
  }
  return json<LoaderData>(result.data)
}
Войти в полноэкранный режим Выход из полноэкранного режима

Видите ли вы закономерность в этих контроллерах?

Если вы хотите увидеть полное приложение Jokes App, реализованное с помощью domain-functions, проверьте этот PR diff!

Сохранение закономерности

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

Должны ли мы добавлять try/catch в наши контроллеры?
Как мы будем возвращать ошибки ввода и как отличить их от реальных ошибок времени выполнения или сервиса?

domain-functions делает это за вас, имея структурированный способ представления данных и ошибок, вы можете быть уверены, что ответы всегда будут последовательными. Вам также не нужно работать с блоками try/catch, на самом деле мы можем просто бросать ошибки в наших доменных функциях, тот же шаблон Remix использует внутри, поэтому вы можете писать только счастливый путь ваших доменных функций и throw ошибок для обеспечения безопасности типов:

const getJoke = makeDomainFunction(z.object({ id: z.string().nonempty() }), userIdSchema)
  (async ({ id }, jokesterId) => {
    const joke = await db.joke.findOne({ where: { id, jokesterId } })
    if (!joke) throw new Error('Joke not found')
    return joke
  })
Вход в полноэкранный режим Выход из полноэкранного режима

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

const result = {
  success: true,
  data: { id: 1, name: 'Joke name', content: 'Joke content' },
  inputErrors: [],
  environmentErrors: [],
  errors: [],
}
Вход в полноэкранный режим Выйти из полноэкранного режима

В противном случае он будет выглядеть следующим образом:

const result = {
  success: false,
  inputErrors: [],
  environmentErrors: [],
  errors: [{ message: 'Joke not found' }],
}
Войти в полноэкранный режим Выход из полноэкранного режима

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

// app/lib/index.ts
function queryResponse<T>(result: T) {
  if (!response.success)
    throw new Response('Not found', { status: 404 })
  return json<T>(result.data)
}

// app/routes/jokes/$id.tsx
import type { UnpackResult } from 'domain-functions'

type LoaderData = UnpackData<typeof getJoke>
export const loader: LoaderFunction = async ({ params }) => {
  return queryResponse<LoaderData>(await getJoke(params))
}
Вход в полноэкранный режим Выход из полноэкранного режима

Тестирование

Теперь представьте, что вам нужно тщательно протестировать этот оригинальный код.

В настоящее время не существует простого способа сделать это, не издеваясь над API Router. Решение обычно лежит в E2E тестировании.

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

// Soon we'll be writing about how we set up our test database.
// For the sake of brevity, pretend there's already a DB with jokes
describe('getRandomJoke', () => {
  it('returns a joke for the given userId', async () => {
    const { user, jokes } = await setup()
    const result = await getRandomJoke(null, user.id)
    if (!result.success) throw new Error('No jokes to be found!')
    expect(jokes.map(({ id }) => id)).toInclude(result.data.id)
  })
})
Войти в полноэкранный режим Выход из полноэкранного режима

Если вы когда-нибудь пытались провести подобное тестирование на маршрутах Remix, вы, вероятно, будете довольны тем, что только что увидели.

Парсинг структурированных данных из форм

Ок, это не ограничение Remix, а скорее «ограничение» API FormData.
Часто бывает полезно разобрать структурированные данные из форм, например, когда у вас есть вложенные формы или повторяющиеся поля.

FormData может работать только с плоскими структурами, и нам нужно знать структуру данных заранее, чтобы понять, следует ли нам вызывать formData.get('myField') или formData.getAll('myField'). Это, вероятно, не подходит для сложных форм.

Под структурированными данными я подразумеваю создание FormData из этой формы:

<form method="post">
  <input name="numbers[]" value="1" />
  <input name="numbers[]" value="2" />
  <input name="person[0][email]" value="john@doe.com" />
  <input name="person[0][password]" value="1234" />
  <button type="submit">
    Submit
  </button>
</form>
Войти в полноэкранный режим Выйти из полноэкранного режима

интерпретироваться следующим образом:

{
  "numbers": ["1", "2"],
  "person": [{ "email": "john@doe.com", "password": "1234" }]
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Ну, domain-functions использует qs, чтобы сделать это преобразование для вас с помощью inputFromForm:

import { inputFromForm } from 'domain-functions'

const result = await myDomainFunction(await inputFromForm(request))
Вход в полноэкранный режим Выход из полноэкранного режима

Библиотека предоставляет и другие утилиты для выполнения подобной работы.

Сквозная безопасность типов и композиция

Одна из самых больших претензий к Remix (и NextJS) — это отсутствие сквозной безопасности типов.
Поддерживать типы вручную скучно и чревато ошибками. Мы хотели получить опыт, не уступающий tRPC, и теперь, когда наши доменные функции имеют знания о вводе/выводе вашего домена, мы находимся в той же ситуации, что и tRPC, как утверждает его автор:

Что касается типов вывода — tRPC доверяет типу возврата, выведенному из вашего резольвера, что разумно IMO. Эти типы в основном происходят из источников (запросы Prisma и т.д.), которые предоставляют надежные типы.

BTW: Колин также является автором Zod и множества других хороших проектов, и мы не можем переоценить, насколько нам нравятся его проекты.

При работе с доменными функциями, вам не нужно писать типы вручную, так как они выводятся из доменных функций. Следующий GIF показывает DX этого рабочего процесса:

Композиция

Когда мы начали работать над этим проектом, мы не предполагали, что достигнем такой хорошей безопасности типов, и не планировали создать идеальную арифметическую прогрессию, всегда выражая возврат наших доменных функций как Promise<Result<MyData>>.

Поэтому, когда мы столкнулись с проблемой работы с несколькими доменами в одном загрузчике без изменения архитектуры нашего контроллера, ответ был прямо перед нами: создание функций для композиции нескольких функций домена, в результате чего получается… функция домена!

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

import { all, map, pipe } from 'domain-functions'
import type { UnpackData } from 'domain-functions'
import { a, b, c, d, e } from './my-domain-functions.server'

// Transform the successful data, ex:
// { success: true, data: "foo" } => { success: true, data: true }
const x = map(a, Boolean)
// Create a domain function that is a sequence of Domain Functions
// where the output of one is the input of the next
const y = pipe(x, b, c)
// Create a domain function that will run all the given domain functions
// in parallel with `Promise.all`
const getData = all(d, e, y)

type LoaderData = UnpackData<typeof getData>
export const loader: LoaderFunction = async ({ params }) => {
  return queryResponse<LoaderData>(await getData(params))
}

export default function Component() {
  const [
    dataForD, dataForE, dataForY
  ] = useLoaderData<LoaderData>()
  // ...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Вся эта логика и шаблон нашего загрузчика остались нетронутыми!

Этот другой GIF демонстрирует DX для композиций:

Заключение

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

Если вам показалось, что вы запутались в приведенных выше примерах, ознакомьтесь с документацией и примерами domain-functions.

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

Оцените статью
Procodings.ru
Добавить комментарий