Введение
Я люблю разрабатывать Serverless решения и размещать их на AWS. А еще я пишу на TypeScript и не использую Promise
почти никогда, так же как и TypeScript тип any
Моя проблема и при чем тут Type-safe
До TypeScript я несколько лет разрабатывал на Scala и довелось поработать с библиотекой ZIO.
Scala и ZIO научили меня писать основной код без побочных эффектов и работать с ошибками в коде так же естественно, как с основной частью кодовой базы проекта.
Для TypeScript появилась библиотека Effect, которая является портированной версией ZIO, и все те идеи, которые позволяли писать надежный (type-safe) код в Scala, теперь можно использовать и в TypeScript проектах ?
И вот настало время описать проблему: мне не нравится работать с AWS SDK библиотеками для взаимодействия с AWS потому что трудозатратно писать type-safe код, который взаимодействует с AWS. В AWS SDK нет удобной работы с ожидаемыми ошибками.
Кроме того, библиотеки неудобно использовать так, как это советует сам AWS (об этом будет ниже)
Гипотетический пример про ожидаемые ошибки и дефект
Представим, что мне нужно купить сыр чеддер к ужину. Для этого иду в магазин.
Успешным результатом похода в магазин будет сыр.
Но есть высокая вероятность, что его не будет в первом магазине.. Или продавец даст слишком маленький кусок сыра. Возможных ошибок существует множество; они являются естественными, и на них нужно реагировать.
В случае, если в магазине нет сыра, не беда, принимаем решение идти в другой магазин. Если в другом магазине нет нужного размера, то можем попросить кусок побольше или взять похожий сорт сыра. Варианты принятия решений зависят от человека.
Если на улице будет гололед, то я могу подскользнуться и подвернуть ногу. В этом случае, поход к сыру закончится, и его не купить сегодня, потому что в магазин на руках не пойдешь. Это пример дефекта и с него уже не восстановишься как в случае возможных ошибок.
Зачем нужен AWS SDK?
У AWS есть несколько сотен сервисов, и через SDK можно взаимодействовать с такими сервисами. Например вот библиотека для работы с AWS Lambda, ее использую в случаях, когда нужно запустить Lambda функцию или создать новую.
Каждый сервис поддерживает больше количество действий (Action). Например вот полный список Lambda
Для каждого сервиса есть отдельная библиотека, например:
для S3 это @aws-sdk/client-s3
для Lambda это @aws-sdk/client-lambda
для DynamoDB это @aws-sdk/client-dynamodb
Как использовать?
Перед тем как выполнять действия с AWS сервисами, нужно создать клиента.
Можно создать полного клиента (Full client), у которого методы класса будет действиями, а можно создать легкого клиента (Bare bone), у которого есть один метод send
принимающий инстанс класса команды.
Full
import { S3 } from "@aws-sdk/client-s3"
const client = new S3()
client.putObject({
Bucket: "foo",
Key: "bar",
Body: "my content"
})
Bare bone
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
const client = new S3Client()
client.send(new PutObjectCommand({
Bucket: "foo",
Key: "bar",
Body: "my content"
}))
AWS советует использовать Bare Bone
, наверное потому что JS код модуля быстрее загрузится в runtime и нужно чуть меньше памяти. ? Но тогда приходится писать дополнительный код, импортировать команды, создавать объекты этих команд через new
?
В чем проблема AWS SDK V3 for JavaScript?
Все SDK библиотеки написаны на TypeScript, так что типизация есть и эти библиотеки являются вполне рабочим вариантом.
В библиотеках есть TypeScript классы ошибок, это хорошо, но не спасает от проверок через instanceof
, потому что у Promise<T>
есть только один generic параметр и он не для ошибок. ?
По сути все Promise based библиотеки, которые маскируют ожидаемые ошибки, создают проблемы для разработчиков.
AWS SDK - это сгенерированный код
Все SDK библиотеки являются результатом работы кодогенератора, я догадывался и меня удивило когда увидел этот кодогенератор, но потом понял, что примерно 400 пакетов сложно вручную поддерживать, даже если ты AWS ?
Вот этот проект, позволяет AWS создавать клиентов для разных языков программирования из JSON спецификации.
Простые и сложные сценарии
Например, вот два возможных варианта:
загрузить файл в S3 бакет, в случае ошибки "бакета не существует", отправить сообщение, например, в чат мессенджера, с красиво оформленным сообщением. Остальные ошибки - выводить в консоль или отправлять на почту.
создать lambda функцию, в случае ошибки "функция уже существует", обновить код функции. в случае других ошибок идти по другим веткам кода или выводить в консоль.
Тот, кто работает c AWS, может сказать, что второй сценарий (про создание функции) является инфраструктурой и для него используются другие инструменты, такие как Terraform, Serverless framework, AWS CDK, etc. И да, это так, но эти инструменты имеют свои тонкие моменты и почти все они являются оберткой над CloudFormation (отдельным инструментом со своими нюансами).
AWS SDK способен сделать все то, что делает CloudFormation (создать функцию, SQS очередь, API Gateway, IAM роли, S3 бакеты, и тому подобное).
Мое предположение: одна из причин, что через AWS SDK не управляют инфраструктурой, кроется в неудобстве работы с AWS SDK.
SDK отлично справляется в простых случаях где нет ошибок, например, подразумевается, что функция, которая загружает файл в S3 бакет, успешно выполнится ?
Так как сделать AWS SDK удобным?
У меня появилась идея, генерировать обертку над AWS SDK, которая будет предоставлять удобный и type-safe API
Генерация? Это не переусложнение?
Генерация кода является важной частью процесса разработки в некоторых проектах. Например, Protobuf – это универсальный, не зависящий от языка программирования инструмент, который необходимо запустить для генерации кода. Я также помню, как использовал Lombok в Java – этот инструмент тоже генерирует дополнительный Java-код.
Отказаться от AWS SDK и написать все с чистого листа - сложно. Нужно понимать, что AWS SDK покрывает функциональность:
сериализация / десериализация команд и ответов
криптография: подписывание запросов
TypeScript классы команд и клиент
Поэтому я решил написать библиотеку, которая генерирует обертку для сгенерированной AWS SDK, надеюсь, вы не запутались и поняли ?
Цель
Сделать NPM пакет, в котором будет кодогенератор оберток для AWS SDK библиотек.
NPM пакет можно будет подключать к любым проектам и генерировать обертки для AWS SDK, которые используются на проекте.
Под каждый пакет AWS SDK будет генерироваться свой TypeScript файл с соответствующими интерфейсами, классами и функциями.
План
Просканировать AWS SDK библиотеку на список всех действий (Action) и получить список возможных ошибок каждого действия.
Создать TypeScript файл с контрактом сервиса
Создать клиента (в том же TypeScript файле). По сути это будет обертка над AWS SDK клиентом, которая будет возвращать не
Promise<A>
аEffect<A, E>
Протестировать на других проектах и опубликовать
1. Сканирование AWS SDK библиотеки
Есть такая библиотека ts-morph, одна из ее мощных функций - анализ TypeScript файлов и навигация по их содержимому.
С помощью ts-morph получилось загрузить TypeScript код AWS SDK библиотек и найти все действия клиента а так же другие сущности (интерфейсы, классы, типы).
Дополнительно к командам, нужно собрать еще ошибки, которые могут возникнуть при выполнение этой команды. AWS SDK пишет указывает эти ошибки в JSDoc классов команд.
Класс действия в AWS SDK библиотеке
Кодогенератор, который создает такие файлы, пишет огромные JSDoс. Я почистил JSDoc для этой статьи, удалил примеры и документацию, оставил только значимое
import { Command as $Command } from "@smithy/smithy-client";
import { MetadataBearer as __MetadataBearer, StreamingBlobPayloadInputTypes } from "@smithy/types";
import { PutObjectOutput, PutObjectRequest } from "../models/models_1";
import { S3ClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../S3Client";
/**
*
* @param PutObjectCommandInput - {@link PutObjectCommandInput}
* @returns {@link PutObjectCommandOutput}
* @see {@link PutObjectCommandInput} for command's `input` shape.
* @see {@link PutObjectCommandOutput} for command's `response` shape.
* @see {@link S3ClientResolvedConfig | config} for S3Client's `config` shape.
*
* @throws {@link EncryptionTypeMismatch} (client fault)
* @throws {@link InvalidRequest} (client fault)
*
* @throws {@link InvalidWriteOffset} (client fault)
*
* @throws {@link TooManyParts} (client fault)
*
* @throws {@link S3ServiceException}
*
*/
export declare class PutObjectCommand extends PutObjectCommand_base {
/** @internal type navigation helper, not in runtime. */
protected static __types: {
api: {
input: PutObjectRequest;
output: PutObjectOutput;
};
sdk: {
input: PutObjectCommandInput;
output: PutObjectCommandOutput;
};
};
}
JSDoc для throws написан не так как это указано в спецификации, поэтому пришлось использовать регулярное выражение чтобы найти все ошибки (а так бы ts-morph помог)
@throws free-form description
@throws {<type>}
@throws {<type>} free-form description
Обработка классов действий
Функция get
фильтрует все классы и выбирает те, которые оканчиваются на Command. И извлекает имена классов ошибок и кладет в exceptions
import { pipe, String, Array, Option, Order } from "effect";
const get = (
input: Pick<ScannedSdkShape, "classes">
) =>
pipe(
input.classes,
Array.filterMap(cls => {
let originName = cls.getName();
if (!originName?.endsWith("Command")) return Option.none();
originName = originName.slice(0, originName.length - 7);
const methodName = makePrettyOperationName(originName);
const comment = cls.getLeadingCommentRanges().flatMap(_ => _.getText()).join("\n");
const exceptions = [] as string[];
const matched = comment.matchAll(throwsRegex);
if (matched) {
exceptions.push(...matched.map(_ => _.at(1)!));
}
return Option.some({
methodName, originName,
inputClassName: String.snakeToPascal(originName),
throws: exceptions
});
}),
Array.dedupeWith((a, b) => a.methodName == b.methodName),
Array.sortWith(_ => _.methodName, Order.string)
);
2. Создание контракта
С помощью ts-morph удобно создавать TypeScript файлы.
На каждую AWS SDK библиотеку, создается соответсвующий .ts
файл.
В этом файле есть несколько вспомогательных функций, но самое главное - контракт клиента. Контракт описывает действия, входные/выходные параметры, и самое главное - возможные ошибки.
Некоторые действия не содержат в JSDoc
throws
аннотаций, в таком случае тип будетnever
для ожидаемых ошибок.
S3Api контракт
export type S3Api = {
abort_multipart_upload: [
Sdk.AbortMultipartUploadCommandInput,
Sdk.AbortMultipartUploadCommandOutput,
never
]
complete_multipart_upload: [
Sdk.CompleteMultipartUploadCommandInput,
Sdk.CompleteMultipartUploadCommandOutput,
never
]
copy_object: [
Sdk.CopyObjectCommandInput,
Sdk.CopyObjectCommandOutput,
{
"ObjectNotInActiveTierError": Sdk.ObjectNotInActiveTierError
}
]
create_bucket: [
Sdk.CreateBucketCommandInput,
Sdk.CreateBucketCommandOutput,
{
"BucketAlreadyExists": Sdk.BucketAlreadyExists,
"BucketAlreadyOwnedByYou": Sdk.BucketAlreadyOwnedByYou
}
]
create_bucket_metadata_table_configuration: [
Sdk.CreateBucketMetadataTableConfigurationCommandInput,
Sdk.CreateBucketMetadataTableConfigurationCommandOutput,
never
]
// и другие действия S3 сервиса
}
Рядом с контрактом, еще я сделал объект, который содержит классы входных аргументов для действий. Это нужно, потому что используется Bare bone тип клиента, который ожидает объекты таких классов, а не просто Plain объекты.
Объект с классами
export const S3CommandFactory: Record<keyof S3Api, new (args: any) => any> = {
abort_multipart_upload: AbortMultipartUploadCommand,
complete_multipart_upload: CompleteMultipartUploadCommand,
copy_object: CopyObjectCommand,
// и другие действия
}
3. Создание клиента
Осталось сделать функцию, которая будет использовать AWS SDK клиента, и возвращать Effect<A, E>
, а не Promise<A>
.
Без особых проблем я написал такую функцию. Ее алгоритм следующий:
Она берет клиента из эффект контекста
находит класс команды и создает объект этого класса
возвращает эффект с результатом выполнения этого действия
пример S3 клиента
Тут
Micro
это тот же самыйEffect
, только его облегченная версия для библиотек использующий Effect вместо Promise.
export function s3<M extends keyof S3Api>(actionName: M, actionInput: S3Api[M][0]) {
return Micro.gen(function*() {
const client = yield* Micro.service(S3Client);
const command = new S3CommandFactory[actionName](actionInput);
return yield* Micro.tryPromise({
try: () => {
console.debug("S3", { actionName });
return client.send(command) as Promise<S3Api[M][1]>
},
catch: error => {
if (error instanceof _ServiceBaseError) {
return new S3Error(error, actionName);
} else {
return { _tag: "#Defect", error } as const;
}
}
}).pipe(
Micro.catchTag("#Defect", _ => Micro.die(_)),
Micro.tap((result) => {
console.debug("S3, success", {
actionName,
statusCode: result.$metadata.httpStatusCode
});
}),
Micro.tapError(error => {
console.debug("S3, error", {
actionName,
name: error.cause.name,
message: error.cause.message,
statusCode: error.cause.$metadata.httpStatusCode
});
return Micro.void;
})
);
})
}
export class S3Client extends
Context.Reference<S3Client>()(
"S3Client",
{
defaultValue() {
return makeS3Client({}).pipe(Micro.runSync);
}
}
)
{
}
export function makeS3Client(config: Sdk.S3ClientConfig) {
return Micro.try({
try: () => new _SdkClient(config),
catch: _ => _
}).pipe(
Micro.orDie
)
}
Извлечение ожидаемых ошибок
Все ожидаемые ошибки от AWS SDK клиента наследуются от класса ServiceException
. Если ошибка не является наследником этого класса то рассматриваем эту ошибку как дефект эффекта.
Класс ожидаемой ошибки от S3
export class S3Error<C extends keyof S3Api, E extends _ServiceBaseError = _ServiceBaseError> {
readonly _tag = "S3Error";
constructor(
readonly cause: E,
readonly command: C
) { }
$is<N extends keyof S3Api[C][2]>(
name: N
): this is S3Error<C, S3Api[C][2][N] & _ServiceBaseError> {
return this.cause.name == name;
}
is<N extends keyof S3Errors>(
name: N
): this is S3Error<C, S3Errors[N]> {
return this.cause.name == name;
}
}
Методы $is и is возвращают тип ошибки по строковому названию этой ошибки. По сути это type guard. Разница между этими методами в том, что $is принимает названия ошибок которые указаны в JSDoc (не везде они указаны), а is принимает названия ошибок всех классов наследуемых от ServiceException
4. Тестирование
Теперь подключаем этот генератор к проекту, где используются aws-sdk библиотеки.
Запускаем кодогенератор в консоли: gen-aws-sdk,
все файлы генерируются в src/generated
Допустим, я хочу написать функцию, которая вернет ARN IAM роли с именем Function1. Если такой роли не будет существовать, то не беда, создаем такую роль и возвращаем ARN этой новой роли.
Get or Create IAM Role
import { lambda } from "./generated/lambda.js";
import { iam } from "./generated/iam.js";
import { readFile } from "fs/promises";
import { Effect, pipe } from "effect";
const getRole =
Effect.gen(function* () {
const existing =
yield* pipe(
iam("get_role", { RoleName: "Function1" }),
Effect.andThen(_ => _.Role),
Effect.catchIf(_ => _.$is("NoSuchEntityException"), () => {
const create =
iam("create_role", {
RoleName: "Function1",
AssumeRolePolicyDocument: JSON.stringify({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
})
}).pipe(
Effect.andThen(_ => _.Role)
);
return create;
}),
Effect.andThen(_ => _?.Arn),
Effect.filterOrFail(_ => _ != null),
);
return existing;
});
// const getRole: Effect.Effect<string, IAMError<"get_role", IAMServiceException> | IAMError<"create_role", IAMServiceException> | NoSuchElementException, never>
Или другой пример, хотим создать Lambda функцию. В случае ошибки "функция уже существует" - обновляем код этой функции.
Create or Update lamda function
const createFunction =
Effect.gen(function* () {
const code = yield* Effect.tryPromise(() => readFile("example.zip"));
const fnName = "hello-effect";
const fn = yield* lambda("create_function", {
Role: yield* getRole,
Code: {
ZipFile: code
},
Runtime: "nodejs22.x",
FunctionName: fnName,
Handler: "index.handler"
}).pipe(
Effect.catchIf(_ => _.$is("ResourceConflictException"), () =>
lambda("update_function_code", {
FunctionName: fnName,
ZipFile: code
})
)
);
}).pipe(
Effect.tapBoth({
onSuccess: () => Effect.logInfo("created"),
onFailure: (error) => Effect.logError("Error", error)
})
)
Публикация
С помощью tsup запаковал кодогенератор и опубликовал в NPM
Подведем итоги
Кодогенератор создает удобную обертку над AWS SDK. Его можно легко подключить к любому проекту.
Теперь взаимодействие с AWS SDK стало надежнее. Можно писать сценарии любой сложности, благодаря тому, что ожидаемые ошибки стали частью основного кода.
Надеюсь, что примером из этой статьи, я смог вдохновить некоторых на то, чтобы погрузиться в возможности библиотеки Effect. Работа с возможными ошибками и дефектами - всего лишь часть того что предлагает Effect.