[Перевод] Frameworkless — бессерверный фреймворк для веб приложений

ab18edac45ea7626e7226d8e72b0ef0d.png

Не думаю, что многие разработчики сегодня используют CGI-скрипты и старый добрый PHP. Поэтому у каждого из нас есть любимый фреймворк, с помощью которого мы и создаём свои веб-приложения. Чем бы мы ни занимались — составлением стандартного юридического контракта или съёмками голливудского блокбастера, — всегда полезно начать с какого-нибудь шаблона. Фреймворк придаёт структуру вашему приложению и избавляет вас от необходимости снова и снова изобретать велосипед. Это может быть навороченная платформа — своего рода конструктор, в котором есть всё, что нужно, даже батарейки (Rails, Django, Spring Boot, Nest), — либо минималистичный, но удобный фреймворк, работающий по принципу «тяп-ляп — и готово» (например, Flask или Express).

Идея веб-фреймворка в том, что есть некий базовый сервисный набор функций, который востребован в большинстве интернет-приложений, — и вот он-то и должен предоставляться в составе стандартной библиотеки. Практически все веб-фреймворки реализуют в полном объёме или выборочно следующий список возможностей:

  • конфигурирование;

  • журналирование;

  • обработка исключений;

  • парсинг HTTP-запросов;

  • маршрутизация запросов к функциям;

  • сериализация;

  • адаптер шлюза (WSGI, Rack, WAR);

  • архитектура промежуточного ПО;

  • архитектура плагинов;

  • сервер разработки.

Фундаменты могут быть удобными.Фундаменты могут быть удобными.

Есть много других функций, но именно эти чрезвычайно распространены. Практически в каждом фреймворке свой особый способ маршрутизации разобранного HTTP-запроса в функцию-обработчик, например call hello () — при получении GET-запроса на /hello.

Вот об этом подходе можно очень много всего интересного рассказать. Никого уже не удивишь возможностью запускать разработанные приложения на любом хосте — от DigitalOcean до Heroku и EC2 — или легко разворачивать веб-сервер в локальной тестовой среде. При этом всегда существует некая кривая обучения — когда вы сначала изучаете все тонкости того, как регистрировать URL-маршрут в данном конкретном фреймворке, или записать в лог отладочное сообщение в другом фреймворке, или, например, добавить собственное поле сериализатора.

Но, может быть, всё же не стоит слепо считать, что веб-приложения всегда должны разрабатываться на каком-то фреймворке. Вместо того чтобы всякий раз бездумно хвататься за привычный инструмент, пора по-новому посмотреть на то, что мы делаем.

Serverless

Меня, честно говоря, поразило, как же много функций, предоставляемых фреймворками, оказываются вообще не нужны, если полностью положиться в своей разработке на AWS. Когда-то давным-давно я смирился с тем, что, фигурально выражаясь, «продал свою душу Джеффу Безосу», — и тогда я начал писать программное обеспечение на платформе AWS. В этом нет ничего особенного — многие разработчики создали успешные приложения, привязанные к разным уровням программной абстракции. На заре развития информатики программист должен был в первую очередь решить, с какой архитектурой набора команд или операционной системой он хотел бы связать жизнь своего приложения. Точно так же и мы сегодня по-прежнему вынуждены принимать решения, которые ставят нас в зависимость от платформы, — только уже на более высоком уровне абстракции. Мой код Python или JavaScript сможет запуститься на любой архитектуре процессора или ОС UNIX —, но функциональность моего облачного провайдера может завязать меня на это конкретное облако. И для меня это абсолютно нормально.

Я давний поклонник serverless-приложений и много писал о них в этом блоге. Мне действительно нравится, когда я могу по максимуму абстрагироваться от инфраструктуры и сосредоточиться на логике моего приложения. Я хочу тратить своё время на работу с бизнес-логикой, а не на возню с контейнерами, развёртыванием систем, настройкой балансировщика нагрузки или gunicorn.

За несколько лет мне удалось принять мировоззрение serverless, но одна вещь меня удерживала — и это моя привязанность к веб-фреймворкам. Несмотря на то что на AWS Lambda довольно часто оказывается уместным писать serverless-функции в виде небольших автономных скриптов, использовать этот метод для создания крупного приложения — это всё равно что строить дом без фундамента. Я порядком успел поэкспериментировать с впихиванием Flask в AWS Lambda — слишком уж хотелось сохранить все удобные фишечки знакомого фреймворка, но так, чтобы все аспекты маршрутизации реализовывались в рамках одной функции. Также при этом можно легко выдернуть приложение из AWS и запустить его где-то ещё.

Однако при включении веб-фреймворка в состав лямбда-функции возникает ряд проблем. Во-первых, конечно же, это — читерство. Во-вторых, когда ваше приложение разрастается до значительных размеров, время холодного запуска становится ощутимой проблемой. Побочный эффект от использования веб-фреймворков в том, что они загружают код вашего приложения при запуске, — и поэтому каждый раз, когда запрос поступает, а разогретого обработчика для него нет, клиент вынужден ждать, пока приложение импортируется полностью, и только после этого он сможет начать обработку запроса. Это означает, что после отправки запроса пользователям иногда приходится ждать по нескольку секунд, — и это не очень здорово с позиций производительности. Существуют простые обходные пути, например механизм provisioned concurrency, однако это явный признак того, что в архитектуре есть какой-то изъян.

Классические веб-фреймворки не подходят для создания по-настоящему бессерверных приложений. Это не тот инструмент, который нужен для этой архитектуры.

Антифреймворк

Предположим, что вы полностью купились на обещания AWS, смирились с зависимостью от этого поставщика — и теперь ваша жизнь прекрасна. AWS сам по себе работает как отдельный фреймворк, предоставляющий все необходимые возможности для работы веб-приложения, —, но делает он это при помощи веб-сервисов из экосистемы Amazon. Если мы говорим о веб-сервисах RESTful, то они позволяют создать чрезвычайно масштабируемое, поддерживаемое и высокодоступное приложение.

  • Журналирование, мониторинг: CloudWatch

  • Трассировка: X-Ray

  • Уведомления: Incident Manager

  • Конфигурирование, перехват исключений, выполнение кода: AWS Lambda

  • Парсинг и маршрутизация HTTP-запросов: API Gateway

  • Реляционная база данных: Aurora Serverless

  • Конфигурирование, секреты: Secrets manager

Не нужно париться со всякими докерами, кубернетесами или балансировщиками нагрузки. Можно даже обойтись без Amazon VPC, если для выполнения SQL-запросов использовать Aurora Data API.

Список можно продолжать бесконечно, но вы уже и так всё поняли. Если мы хотим оставаться как можно более ленивыми и максимально использовать облачные сервисы, то вот что нам нужно — инструмент, позволяющий создавать сервисы так, как мы привыкли. Новый комплект облачной разработки от Amazon — Cloud Development Kit (CDK) — предназначен именно для этого. Если вы никогда раньше не слышали о CDK, вы можете прочитать вот эту простую вводную статью или ознакомиться с официальной документацией.

Если кратко, то CDK позволяет писать высокоуровневый код на Python, TypeScript, Java или .NET — и компилировать его в шаблон CloudFormation, описывающий вашу инфраструктуру. Вот небольшой пример TypeScript из репозитория cursed-webring:

// API-шлюз с включенной опцией CORS
const api = new RestApi(this, "cursed-api", {
  restApiName: "Cursed Service",
  defaultCorsPreflightOptions: {
    allowOrigins: apigateway.Cors.ALL_ORIGINS,
  },
  deployOptions: { tracingEnabled: true },
});
// Определяет ресурс /sites/ внутри нашего API
const sitesResource = api.root.addResource("sites");
// Получаем обработчик всех сайтов, GET /sites/
const getAllSitesHandler = new NodejsFunction(
  this,
  "GetCursedSitesHandler",
  {
    entry: "resources/cursedSites.ts",
    handler: "getAllHandler",
    tracing: Tracing.ACTIVE,
  }
);
sitesResource.addMethod("GET", new LambdaIntegration(getAllSitesHandler));

Можно ли назвать CDK фреймворком? Это зависит от того, как вы определяете слово «фреймворк», но для меня это больше сервис «инфраструктура как код». Позволяя вам без особых усилий подключать необходимые приложению сервисы, CDK более чётко устраняет потребность в традиционном веб-фреймворке, когда речь заходит о таких функциях, как маршрутизация или формирование ответов на HTTP-запросы.

Несмотря на то что CDK — это отличный способ объединить AWS-сервисы, он мало чем полезен для самого прикладного кода. Чтобы ещё сильнее облениться и глубже утонуть в диване, мы можем декорировать код нашего приложения метаданными, которые будут генерировать CDK-ресурсы, объявленные нашим приложением, — например, функции Lambda и маршруты API Gateway. Вот это я называю антифреймворком.

@JetKit/CDK

Чтобы воплотить всё это в жизнь, мы создали антифреймворк под названием @jetkit/cdk. Это TypeScript-библиотека, которая позволяет декорировать функции и классы так, как если бы вы пользовались традиционным веб-фреймворком, при этом AWS-ресурсы автоматически генерируются из кода приложения.

Концепция проста. Вы пишете функции как обычно, а затем добавляете метаданные, отражающие особенности интеграции с AWS, например настройки Lambda или маршруты API:

import { HttpMethod } from "@aws-cdk/aws-apigatewayv2"
import { Lambda, ApiEvent } from "@jetkit/cdk"
// Простая автономная функция с прикреплённым к ней маршрутом
export async function aliveHandler(event: ApiEvent) {
  return "i'm alive"
}
// Определяем маршрут и свойства Lambda
Lambda({
  path: "/alive",
  methods: [HttpMethod.GET],
  memorySize: 128,
})(aliveHandler)

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

import { HttpMethod } from "@aws-cdk/aws-apigatewayv2"
import { badRequest, methodNotAllowed } from "@jdpnielsen/http-error"
import { ApiView, SubRoute, ApiEvent, ApiResponse, ApiViewBase, apiViewHandler } from "@jetkit/cdk"
@ApiView({
  path: "/album",
  memorySize: 512,
  environment: {
    LOG_LEVEL: "DEBUG",
  },
  bundling: { minify: true, metafile: true, sourceMap: true },
})
export class AlbumApi extends ApiViewBase {
  // Определяем обработчик POST-запросов
  post = async () => "Created new album"
  // Пользовательский эндпойнт в представлении
  // Маршруты для функции ApiViewBase
  @SubRoute({
    path: "/{albumId}/like", // Получится /album/123/like
    methods: [HttpMethod.POST, HttpMethod.DELETE],
  })
  async like(event: ApiEvent): ApiResponse {
    const albumId = event.pathParameters?.albumId
    if (!albumId) throw badRequest("albumId is required in path")
    const method = event.requestContext.http.method
    // POST — отметили альбом как понравившийся
    if (method == HttpMethod.POST) return `Liked album ${albumId}`
    // DELETE — альбом перестал нравиться, снимаем отметку
    else if (method == HttpMethod.DELETE) return `Unliked album ${albumId}`
    // До этой части исполнение кода не должно доходить
    else return methodNotAllowed()
  }
}
export const handler = apiViewHandler(__filename, AlbumApi)

Декораторы — это не какая-то магия; они просто сохраняют вашу конфигурацию в виде метаданных на уровне класса. Они делают то же самое, что и функция  Lambda (), приведённая выше. Эти метаданные считываются позже, когда для вас создаются соответствующие конструкции CDK. ApiViewBase содержит базовую функциональность, необходимую для передачи данных соответствующему методу внутри класса в зависимости от входящего HTTP-запроса.

Разве это не есть «маршрутизация»? В какой-то мере да. Класс AlbumApi — это одна Lambda-функция, которая предназначена для организации вашего кода и сохранения объёма ресурсов в стеке CloudFormation на более-менее разумном уровне. Однако он создаёт несколько маршрутов API Gateway, поэтому API Gateway всё так же отвечает за первоначальный парсинг и маршрутизацию HTTP-запроса. Если вы борец за чистоту кода, то вы, конечно, можете создать по отдельной Lambda-функции на каждый маршрут, используя обертку Lambda (), — если уж так хочется. Наша задача в данном случае — не погрешить против святой простоты.

Причина того, что Lambda () не является декоратором, состоит в том, что декораторы функций на данный момент не поддерживаются в TypeScript из-за осложнений, связанных с механизмом поднятия функций.

Почему именно TypeScript?

Хочу сразу оговориться, что TypeScript — это мой любимый на сегодняшний день язык бэкенд-разработки. JavaScript — нет, а TypeScript — да. Быстрые темпы развития этого языка, который создавался под эгидой Microsoft, со множеством важных улучшений, безусловно, впечатляют. Строгость этого языка способна выдержать любую критику. Для разработческой команды также гораздо проще, когда можно использовать единый набор инструментов, CI/CD-конвейеров, документации, библиотек — и при этом не требуется опыт разработки сразу на двух языках программирования. Весь фронтенд, с которым мы работаем, — это React и TypeScript. Почему бы тогда не пользоваться одними и теми же инструментами контроля качества кода, проверками типов, триггерами, репозиториями, конфигурациями форматирования, а также инструментами сборки — вместо того чтобы поддерживать, скажем, один набор для бэкенда на Python, а другой — для фронтенда на TypeScript?

С Python всё в полном ажуре, если не считать отсутствия чувства защищённости, которое может дать только строгая типизация. И не нужно спамить мне в комментах о прелестях mypy или pylance. Ещё скажите, что Taco Bell — это прекрасная такерия. Может быть, они и помогут продержаться день —, но вряд ли их хватит на большее.

Генерация конструкций

Итак, мы уже видели декорированный прикладной код. Как же он превращается в облачные ресурсы? Есть такой CDK-конструкт, который называется ResourceGeneratorConstruct. Он принимает ваши функции и классы в качестве входных данных — и генерирует ресурсы AWS в качестве выходных.

import { CorsHttpMethod, HttpApi } from "@aws-cdk/aws-apigatewayv2"
import { Construct, Duration, Stack, StackProps, App } from "@aws-cdk/core"
import { ResourceGeneratorConstruct } from "@jetkit/cdk"
import { aliveHandler, AlbumApi } from "../backend/src"  // Ваш прикладной код
export class InfraStack extends Stack {
  constructor(scope: App, id: string, props?: StackProps) {
    super(scope, id, props)
    // Создаём API-шлюз
    const httpApi = new HttpApi(this, "Api", {
      corsPreflight: {
        allowHeaders: ["Authorization"],
        allowMethods: [CorsHttpMethod.ANY],
        allowOrigins: ["*"],
        maxAge: Duration.days(10),
      },
    })
    // Преобразуем код вашего приложения в инфраструктуру
    new ResourceGeneratorConstruct(this, "Generator", {
      resources: [AlbumApi, aliveHandler], // Вставьте сюда свои API-views и функции
      httpApi,
    })
  }
}

Нужно в явном виде передать в генератор функции и классы, для которых вы хотите сгенерировать ресурсы. В противном случае esbuild проведёт оптимизацию — и удалит их.

Попробуйте сами

@jetkit/cdk распространяется по лицензии MIT, имеет открытый исходный код, документацию и отличные тесты. Он делает минимум необходимых вещей, и в этом его прелесть.

Если вам хочется как можно скорее попробовать, клонируйте этот шаблон проекта TypeScript — и у вас будет современный бессерверный монорепозиторий на базе NPM v7 Workspaces.

Может быть, в конце концов, фундамент и не нужен.Может быть, в конце концов, фундамент и не нужен.

П.С. От переводчика.

Сама сфера serverless достаточно бурно развивается и у нас есть активно растущее serverless-коммьюнити Yandex Serverless Ecosystem в Telegram, где можно задавать вопросы, возникающие в процессе создания serverless-приложений и получать ответы от единомышленников и коллег.

А для тех кто хочет еще больше погрузиться в мир serverless в этот четверг пройдет онлайн-митап about: cloud о Serverless, приходите, будет много интересного про serverless экосистему, СУБД, производительность и многое другое.

© Habrahabr.ru