Цель данной статьи — создать GraphQL приложение, построенное на фреймворке NestJS. А также загрузить его в Лямбда-функцию при помощи Terraform. Надеюсь данный пример поможет многим сэкономить много времени.
Приложение будет работать с реляционной базой данных PostgreSQL. Для локального использования возьмем docker-compose:
version: '3.1' services: db: image: 'postgres:14.1' restart: unless-stopped volumes: - ./volumes/postgresql/data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: example POSTGRES_DB: nest ports: - 5432:5432 networks: - postgres networks: postgres: driver: bridge

Создадим новый проект (для этого необходимо установить Nest CLI):
nest new app
Добавим модуль и сервис пользователя:
nest generate module user nest generate service user
Добавим модель пользователя, которая будет одновременно и моделью базы и описанием объекта для GraphQL:
@Entity() @ObjectType() export class User { @PrimaryGeneratedColumn() @Field(type => Int) id: number; @Column({nullable: false}) @Field({nullable: false}) name: string; @Column({nullable: true}) @Field({nullable: true}) dob: Date; @Column({nullable: true}) @Field({nullable: true}) address: string; @Column({nullable: true}) @Field({nullable: true}) description: string; @Column({nullable: true}) @Field({nullable: true}) imageUrl: string; @Column({nullable: true, default: new Date()}) @Field({nullable: true}) createdAt: Date; @Column({nullable: true, default: new Date()}) @Field({nullable: true}) updatedAt: Date; }
Опишем сервис пользователя так, чтобы он решал стандартные задачи CRUD, а также поиск по имени пользователя с пагинацией
@Injectable() export class UserService { constructor( @Inject(USER_REPOSITORY) private userRepository: Repository<User> ) { } create(data: TUserCreate): Promise<User> { const user = this.userRepository.create(data) return this.userRepository.save(user) } findById(id: number): Promise<User> { return this.userRepository.findOne(id) } async findAll(searchText: string = '', take: number = 10, skip: number = 0): Promise<UserSearchResult> { const query = searchText ? { where: [ {name: ILike('%'+searchText+'%')} ] } : {} const getQuery = { ...query, take, skip, order: { name: "ASC", } } const [total, list] = await Promise.all([this.userRepository.count(query), this.userRepository.find(getQuery as FindManyOptions)]) return { total, list } as UserSearchResult } async updateById(id: number, data: TUserUpdate): Promise<User> { await this.userRepository.update({id}, data) return this.findById(id) } async deleteById(id: number): Promise<boolean> { return !!(await this.userRepository.delete({id})) } }
И теперь соединим их при помощи класса-резолвера:
@Resolver(of => User) export class UsersResolver { constructor( private userService: UserService, ) { } @Mutation(returns => User) async createUser( @Args('name', {type: () => String}) name: string, @Args('address', {type: () => String}) address: string = '', @Args('description', {type: () => String}) description: string = '', @Args('imageUrl', {type: () => String}) imageUrl: string = '', @Args('dob', {type: () => String}) dob: string = null, ) { return this.userService.create({ name, address, description, imageUrl, dob: dob ? new Date(dob) : null } as TUserCreate); } @Query(returns => User) async getUser(@Args('id', {type: () => Int}) id: number) { return this.userService.findById(id); } @Query(returns => UserSearchResult) async getAllUsers( @Args('searchText', {type: () => String}) searchText: string, @Args('take', {type: () => Int}) take: number=10, @Args('skip', {type: () => Int}) skip: number=0, ) { return this.userService.findAll(searchText, take, skip) } @Mutation(returns => User) async updateUser( @Args('id', {type: () => Int}) id: number, @Args('name', {type: () => String}) name: string, @Args('address', {type: () => String}) address: string = '', @Args('description', {type: () => String}) description: string = '', @Args('imageUrl', {type: () => String}) imageUrl: string = '', @Args('dob', {type: () => String}) dob: string = null, ) { return this.userService.updateById(id, { name, address, description, imageUrl, dob: dob ? new Date(dob) : null } as TUserUpdate); } @Mutation(returns => Boolean) async deleteUser(@Args('id', {type: () => Int}) id: number) { return this.userService.deleteById(id); } }
В модуль пользователя добавим инициализацию GraphQL. Параметры стандартные и это все очень хорошо описано в документации по GraphQL для NestJS, но есть очень важный момент.
По умолчанию GraphQL Playground запускается по пути /graphql. Мы будем деплоить наше приложение в Lambda через ApiGateway, который должен иметь stage с каким-то именем, что дает префикс к любому пути, например /api. Поэтому нужно перенести путь для GraphQL Playground с /graphql в /api/graphql. Для этого используем параметр useGlobalPrefix:true. А также при инициализации express добавим app.setGlobalPrefix('api');
@Module({ imports: [ DatabaseModule, GraphQLModule.forRootAsync({ useFactory: () => { const schemaModuleOptions: Partial<GqlModuleOptions> = {}; // If we are in development, we want to generate the schema.graphql if (process.env.NODE_ENV !== 'production' || process.env.IS_OFFLINE) { schemaModuleOptions.autoSchemaFile = 'src/user/user.schema.gql'; } else { // For production, the file should be generated schemaModuleOptions.typePaths = ['*.gql']; } return { context: ({req}) => ({req}), useGlobalPrefix:true, // <== playground: true, // Allow playground in production introspection: true, // Allow introspection in production ...schemaModuleOptions, }; } } as GqlModuleAsyncOptions), ], providers: [ ...userProviders, UserService, UsersResolver ] })
Запустим Playground локально:


Для запуска в Lambda необходимо подменить создание сервера express на aws-serverless-express
Создадим app.ts:
import {NestFactory} from '@nestjs/core'; import {ExpressAdapter} from '@nestjs/platform-express'; import {INestApplication} from '@nestjs/common'; import {AppModule} from './app.module'; import * as express from 'express'; import {Express} from 'express'; import {Server} from "http"; import {createServer} from 'aws-serverless-express'; export async function createApp( expressApp: Express, ): Promise<INestApplication> { const app = await NestFactory.create( AppModule, new ExpressAdapter(expressApp), ); app.setGlobalPrefix('api'); return app; } export async function bootstrap(): Promise<Server> { const expressApp = express(); const app = await createApp(expressApp); await app.init(); return createServer(expressApp); }
А также файл с handler функцией лямбды:
import {Server} from 'http'; import {Context} from 'aws-lambda'; import {proxy, Response} from 'aws-serverless-express'; import {bootstrap} from './app'; let cachedServer: Server; export async function handler(event: any, context: Context): Promise<Response> { if (!cachedServer) { cachedServer = await bootstrap(); } return proxy(cachedServer, event, context, 'PROMISE').promise; }
Осталось задеплоить это все в AWS. Для этого воспользуемся Terraform. Создадим папку terraform, а в ней файл main.tf . Дальше кидаю готовый конфиг с комментариями по каждому действию:
# Зададим регион по умолчанию provider "aws" { region = "us-east-1" } # Деплоить лямбду будем через zip архив. Поэтому необходимо положить наш код в архив data "archive_file" "app_zip" { type = "zip" source_dir = "../app/dist" output_path = "./app.zip" } # Создадим API GW resource "aws_apigatewayv2_api" "app" { name = "api" protocol_type = "HTTP" } # И добавим в него stage. resource "aws_apigatewayv2_stage" "app" { api_id = aws_apigatewayv2_api.app.id name = "api" auto_deploy = true # добавим логирования API GW в CloudWatch access_log_settings { destination_arn = aws_cloudwatch_log_group.api_gw.arn format = jsonencode({ requestId = "$context.requestId" sourceIp = "$context.identity.sourceIp" requestTime = "$context.requestTime" protocol = "$context.protocol" httpMethod = "$context.httpMethod" resourcePath = "$context.resourcePath" routeKey = "$context.routeKey" status = "$context.status" responseLength = "$context.responseLength" integrationErrorMessage = "$context.integrationErrorMessage" } ) } } # Создадим интеграцию Lambda в API GW resource "aws_apigatewayv2_integration" "app" { api_id = aws_apigatewayv2_api.app.id integration_uri = aws_lambda_function.app.invoke_arn integration_type = "AWS_PROXY" integration_method = "POST" } # Добавим Route - любой route должен вызывать нашу лямбду resource "aws_apigatewayv2_route" "app" { api_id = aws_apigatewayv2_api.app.id route_key = "ANY /{proxy+}" target = "integrations/${aws_apigatewayv2_integration.app.id}" } # Добавим лог группу в Cloud Watch для API GW resource "aws_cloudwatch_log_group" "api_gw" { name = "/aws/api_gw/${aws_apigatewayv2_api.app.name}" retention_in_days = 30 } # Добавим достум API GW вызывать лямбда функцию resource "aws_lambda_permission" "api_gw" { statement_id = "AllowExecutionFromAPIGateway" action = "lambda:InvokeFunction" function_name = aws_lambda_function.app.function_name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.app.execution_arn}/*/*" } # Создадим Security Group для базы данных и настроем ее так, чтоб можно было достучаться до нее из вне # Внимание это настройка только для демо. для продакшн так делать нельзя. resource "aws_security_group" "allow_db" { name = "allow_db" description = "Allow DB" ingress { from_port = 5430 to_port = 5440 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] ipv6_cidr_blocks = ["::/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] ipv6_cidr_blocks = ["::/0"] } } # Создадим рандомный пароль для базы resource "random_password" "password" { length = 20 special = false override_special = "_%@" } # Создадим инстанс базы resource "aws_db_instance" "default" { allocated_storage = 20 db_subnet_group_name = aws_db_subnet_group.db_subnet_group.name engine = "postgres" identifier = "dev-db" engine_version = "13" instance_class = "db.t3.micro" name = "nest" username = "postgres" password = random_password.password.result skip_final_snapshot = true publicly_accessible = true vpc_security_group_ids = [aws_security_group.allow_db.id] } # Настроим подсеть 'a' для региона us-east-1 resource "aws_default_subnet" "db_subnet_a" { availability_zone = "us-east-1a" tags = { Name = "Default subnet for us-east-1a" } } # Настроим подсеть 'b' для региона us-east-1 resource "aws_default_subnet" "db_subnet_b" { availability_zone = "us-east-1b" tags = { Name = "Default subnet for us-east-1b" } } # Объеденим подсети в группу resource "aws_db_subnet_group" "db_subnet_group" { name = "db_subnet_group" subnet_ids = [aws_default_subnet.db_subnet_a.id, aws_default_subnet.db_subnet_b.id] } # Создать лямбда функцию, используя архив с кодом resource "aws_lambda_function" "app" { filename = data.archive_file.app_zip.output_path source_code_hash = data.archive_file.app_zip.output_base64sha256 function_name = "app" handler = "serverless.handler" runtime = "nodejs14.x" memory_size = 1024 role = aws_iam_role.lambda_exec.arn timeout = 30 # зададим перенные окружения, указав доступ к базе environment { variables = { POSTGRES_HOST = aws_db_instance.default.address POSTGRES_PORT = aws_db_instance.default.port POSTGRES_USER = aws_db_instance.default.username POSTGRES_PASSWORD = random_password.password.result POSTGRES_DATABASE = aws_db_instance.default.name NODE_ENV = "production" } } } # Добавим лог группу в CloudWatch для лямбда-функции resource "aws_cloudwatch_log_group" "app" { name = "/aws/lambda/${aws_lambda_function.app.function_name}" retention_in_days = 30 } # Создать роль для лямбды resource "aws_iam_role" "lambda_exec" { name = "serverless_lambda" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Sid = "" Principal = { Service = "lambda.amazonaws.com" } } ] }) } # Присоединим стандартный полиси к роли с доступ к VPC resource "aws_iam_role_policy_attachment" "lambda_policy" { role = aws_iam_role.lambda_exec.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" }
terraform apply

После чего в вашем AWS аккаунте создадутся нужные ресуры:

Как видим Terraform очень удобен для создания и менеджмента ресурсов в облаке. Можно легко поменять аккаунт AWS и развернуть все в нем, а также уничтожить все ресурсы одной командой terraform destroy.
Теперь запустим GraphQL Playground в лямбде:

В итоге у нас получилась лямбда функция с GraphQL основанная на фреймворке NestJS и задеплоенная при помощи Terraform. Используя данный пример вы сможете реалзиовать свои проекты на схожих технологиях. Полный код можно глянуть тут.
ссылка на оригинал статьи https://habr.com/ru/post/599797/
Добавить комментарий