Перейти к основному содержимому

Написание AWS Lambda корпоративного уровня на Java

· 14 мин. чтения

1. Обзор

Чтобы собрать базовую AWS Lambda на Java, не требуется много кода. Чтобы не усложнять задачу, мы обычно создаем бессерверные приложения без поддержки фреймворка.

Однако, если нам нужно развернуть и контролировать наше программное обеспечение на уровне предприятия, нам нужно решить многие проблемы, которые решаются «из коробки» с помощью таких фреймворков, как Spring.

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

2. Создание примера

2.1. Параметры фреймворка

Такие платформы, как Spring Boot, нельзя использовать для создания AWS Lambdas. У Lambda другой жизненный цикл, чем у серверного приложения, и он взаимодействует со средой выполнения AWS без прямого использования HTTP.

Spring предлагает Spring Cloud Function , которая может помочь нам создать AWS Lambda, но нам часто нужно что-то поменьше и попроще.

Мы будем черпать вдохновение из DropWizard , который имеет меньший набор функций, чем Spring, но по-прежнему поддерживает общие стандарты, включая настраиваемость, ведение журнала и внедрение зависимостей.

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

2.2. Пример проблемы

Давайте создадим приложение, которое запускается каждые несколько минут. Он просматривает «список дел», находит самую старую работу, которая не отмечена как выполненная, а затем создает сообщение в блоге в качестве предупреждения. Он также будет создавать полезные журналы, позволяющие аварийным сигналам CloudWatch предупреждать об ошибках.

Мы будем использовать API-интерфейсы JsonPlaceholder в качестве серверной части и сделаем приложение настраиваемым как для базовых URL-адресов API, так и для учетных данных, которые мы будем использовать в этой среде.

2.3. Базовая настройка

Мы будем использовать интерфейс командной строки AWS SAM для создания базового примера Hello World .

Затем мы изменим класс App по умолчанию , в котором есть пример обработчика API, на простой RequestStreamHandler , который регистрируется при запуске:

public class App implements RequestStreamHandler {

@Override
public void handleRequest(
InputStream inputStream,
OutputStream outputStream,
Context context) throws IOException {
context.getLogger().log("App starting\n");
}
}

Поскольку наш пример не является обработчиком API, нам не нужно будет читать какие-либо входные данные или производить какие-либо выходные данные. Прямо сейчас мы используем LambdaLogger внутри контекста , переданного нашей функции, для ведения журнала, хотя позже мы рассмотрим, как использовать Log4j и Slf4j .

Давайте быстро проверим это:

$ sam build
$ sam local invoke

Mounting todo-reminder/.aws-sam/build/ToDoFunction as /var/task:ro,delegated inside runtime container
App starting
END RequestId: 2aaf6041-cf57-4414-816d-76a63c7109fd
REPORT RequestId: 2aaf6041-cf57-4414-816d-76a63c7109fd Init Duration: 0.12 ms Duration: 121.70 ms
Billed Duration: 200 ms Memory Size: 512 MB Max Memory Used: 512 MB

Наше приложение-заглушка запустилось и записало «Запуск приложения» в журналы.

3. Конфигурация

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

3.1. Добавление переменных среды в шаблон

Файл template.yaml содержит настройки лямбды . Мы можем добавить переменные среды в нашу функцию, используя раздел Environment в разделе AWS::Serverless::Function :

Environment: 
Variables:
PARAM1: VALUE

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

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

Для начала добавим в самый верх файла template.yaml параметр с именем среды по умолчанию:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: todo-reminder application

Parameters:
EnvironmentName:
Type: String
Default: dev

Затем давайте свяжем этот параметр с переменной среды в разделе AWS::Serverless::Function :

Environment: 
Variables:
ENV_NAME: !Ref EnvironmentName

Теперь мы готовы прочитать переменную среды во время выполнения.

3.2. Чтение переменной среды

Давайте прочитаем переменную среды ENV_NAME при построении нашего объекта App :

private String environmentName = System.getenv("ENV_NAME");

Мы также можем регистрировать среду, когда вызывается handleRequest :

context.getLogger().log("Environment: " + environmentName + "\n");

Сообщение журнала должно заканчиваться на «\n» , чтобы разделить строки журнала. Мы можем увидеть вывод:

$ sam build
$ sam local invoke

START RequestId: 12fb0c05-f222-4352-a26d-28c7b6e55ac6 Version: $LATEST
App starting
Environment: dev

Здесь мы видим, что среда была установлена по умолчанию в template.yaml .

3.3. Изменение значений параметров

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

$ sam local invoke --parameter-overrides "ParameterKey=EnvironmentName,ParameterValue=test"

START RequestId: 18460a04-4f8b-46cb-9aca-e15ce959f6fa Version: $LATEST
App starting
Environment: test

3.4. Модульное тестирование с переменными среды

Поскольку переменная среды является глобальной для приложения, у нас может возникнуть соблазн инициализировать ее в частной статической финальной константе. Однако это очень затрудняет модульное тестирование.

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

Мы можем использовать системные заглушки , чтобы установить переменную среды, и глубокие заглушки Mockito , чтобы сделать наш LambdaLogger тестируемым внутри Context . Во-первых, мы должны добавить MockitoJUnitRunner в тест:

@RunWith(MockitoJUnitRunner.class)
public class AppTest {

@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private Context mockContext;

// ...
}

Затем мы можем использовать EnvironmentVariablesRule , чтобы позволить нам управлять переменной среды до создания объекта App :

@Rule
public EnvironmentVariablesRule environmentVariablesRule =
new EnvironmentVariablesRule();

Теперь мы можем написать тест:

environmentVariablesRule.set("ENV_NAME", "unitTest");
new App().handleRequest(fakeInputStream, fakeOutputStream, mockContext);

verify(mockContext.getLogger()).log("Environment: unitTest\n");

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

4. Работа со сложными конфигурациями

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

Мы можем использовать System.getenv несколько раз и даже использовать Optional и orElse , чтобы перейти к значению по умолчанию:

String setting = Optional.ofNullable(System.getenv("SETTING"))
.orElse("default");

Однако для этого может потребоваться много повторяющегося кода и координация большого количества отдельных String s.

4.1. Представьте конфигурацию как POJO

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

public class Config {
private String toDoEndpoint;
private String postEndpoint;
private String environmentName;

// getters and setters
}

Теперь мы можем создавать наши компоненты среды выполнения с текущей конфигурацией:

public class ToDoReaderService {
public ToDoReaderService(Config configuration) {
// ...
}
}

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

private Credentials toDoCredentials;
private Credentials postCredentials;

Пока это просто шаблон проектирования. Давайте посмотрим, как загрузить эти значения на практике.

4.2. Загрузчик конфигурации

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

Давайте добавим зависимость в наш pom.xml :

<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>lightweight-config</artifactId>
<version>1.1.0</version>
</dependency>

Затем добавим файл configuration.yml в наш каталог src/main/resources . Этот файл отражает структуру нашей конфигурации POJO и содержит жестко закодированные значения, заполнители для заполнения из переменных среды и значения по умолчанию:

toDoEndpoint: https://jsonplaceholder.typicode.com/todos
postEndpoint: https://jsonplaceholder.typicode.com/posts
environmentName: ${ENV_NAME}
toDoCredentials:
username: foreach
password: ${TODO_PASSWORD:-password}
postCredentials:
username: foreach
password: ${POST_PASSWORD:-password}

Мы можем загрузить эти настройки в наш POJO с помощью ConfigLoader :

Config config = ConfigLoader.loadYmlConfigFromResource("configuration.yml", Config.class);

Это заполняет выражения-заполнители из переменных среды, применяя значения по умолчанию после выражений :- . Он очень похож на загрузчик конфигурации, встроенный в DropWizard.

4.3. Хранение контекста где-то

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

Давайте создадим класс с именем ExecutionContext , который приложение может использовать для создания объекта:

public class ExecutionContext {
private Config config;
private ToDoReaderService toDoReaderService;

public ExecutionContext() {
this.config =
ConfigLoader.loadYmlConfigFromResource("configuration.yml", Config.class);
this.toDoReaderService = new ToDoReaderService(config);
}
}

Приложение может создать один из них в своем списке инициализаторов:

private ExecutionContext executionContext = new ExecutionContext();

Теперь, когда приложению нужен «бин», оно может получить его из этого объекта.

5. Лучшее ведение журнала

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

Самое главное, когда мы сталкиваемся с ошибками, мы должны регистрировать их с большим количеством полезной информации, и Logger.error обычно лучше справляется с этой задачей, чем самодельный код.

5.1. Добавьте библиотеку AWS Log4j

Мы можем включить среду выполнения AWS lambda Log4j , добавив зависимости в наш pom.xml :

<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-log4j2</artifactId>
<version>1.2.0</version>
</dependency>

Нам также нужен файл log4j2.xml в src/main/resources , настроенный для использования этого регистратора:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.amazonaws.services.lambda.runtime.log4j2">
<Appenders>
<Lambda name="Lambda">
<PatternLayout>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n</pattern>
</PatternLayout>
</Lambda>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Lambda" />
</Root>
</Loggers>
</Configuration>

5.2. Написание заявления о регистрации

Теперь мы добавляем стандартный шаблон Log4j Logger в наши классы:

public class ToDoReaderService {
private static final Logger LOGGER = LogManager.getLogger(ToDoReaderService.class);

public ToDoReaderService(Config configuration) {
LOGGER.info("ToDo Endpoint on: {}", configuration.getToDoEndpoint());
// ...
}

// ...
}

Затем мы можем протестировать его из командной строки:

$ sam build
$ sam local invoke

START RequestId: acb34989-980c-42e5-b8e4-965d9f497d93 Version: $LATEST
2021-05-23 20:57:15 INFO ToDoReaderService - ToDo Endpoint on: https://jsonplaceholder.typicode.com/todos

5.3. Вывод журнала модульного тестирования

В случаях, когда вывод журнала тестирования важен, мы можем сделать это с помощью системных заглушек. Наша конфигурация, оптимизированная для AWS Lambda, направляет вывод журнала в System.out , который мы можем нажать:

@Rule
public SystemOutRule systemOutRule = new SystemOutRule();

@Test
public void whenTheServiceStarts_thenItOutputsEndpoint() {
Config config = new Config();
config.setToDoEndpoint("https://todo-endpoint.com");
ToDoReaderService service = new ToDoReaderService(config);

assertThat(systemOutRule.getLinesNormalized())
.contains("ToDo Endpoint on: https://todo-endpoint.com");
}

5.4. Добавление поддержки Slf4j

Мы можем добавить Slf4j , добавив зависимость:

<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j-impl</artifactId>
<version>2.13.2</version>
</dependency>

Это позволяет нам видеть сообщения журнала из библиотек с поддержкой Slf4j . Мы также можем использовать его напрямую:

public class ExecutionContext {
private static final Logger LOGGER =
LoggerFactory.getLogger(ExecutionContext.class);

public ExecutionContext() {
LOGGER.info("Loading configuration");
// ...
}

// ...
}

Журнал Slf4j направляется через среду выполнения AWS Log4j :

$ sam local invoke

START RequestId: 60b2efad-bc77-475b-93f6-6fa7ddfc9f88 Version: $LATEST
2021-05-23 21:13:19 INFO ExecutionContext - Loading configuration

6. Использование REST API с Feign

Если наша Lambda использует службу REST, мы можем напрямую использовать библиотеки Java HTTP. Тем не менее, есть преимущества использования облегченной структуры.

OpenFeign — отличный вариант для этого. Это позволяет нам подключать выбранные нами компоненты для HTTP-клиента, ведения журнала, анализа JSON и многого другого.

6.1. Добавление

В этом примере мы будем использовать клиент Feign по умолчанию, хотя клиент Java 11 также является очень хорошим вариантом и работает со средой выполнения Lambda java11 на основе Amazon Corretto.

Кроме того, мы будем использовать ведение журнала Slf4j и Gson в качестве библиотеки JSON:

<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-core</artifactId>
<version>11.2</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-slf4j</artifactId>
<version>11.2</version>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-gson</artifactId>
<version>11.2</version>
</dependency>

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

6.2. Определение фиктивного интерфейса

Во-первых, мы опишем API, который мы будем вызывать с помощью интерфейса:

public interface ToDoApi {
@RequestLine("GET /todos")
List<ToDoItem> getAllTodos();
}

Это описывает путь в API и любые объекты, которые должны быть созданы из ответа JSON. Давайте создадим ToDoItem для моделирования ответа от нашего API:

public class ToDoItem {
private int userId;
private int id;
private String title;
private boolean completed;

// getters and setters
}

6.3. Определение клиента из интерфейса

Далее мы используем Feign.Builder для преобразования интерфейса в клиент:

ToDoApi toDoApi = Feign.builder()
.decoder(new GsonDecoder())
.logger(new Slf4jLogger())
.target(ToDoApi.class, config.getToDoEndpoint());

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

.requestInterceptor(
new BasicAuthRequestInterceptor(
config.getToDoCredentials().getUsername(),
config.getToDoCredentials().getPassword()))

7. Соединение объектов вместе

К этому моменту мы создали конфигурации и bean-компоненты для нашего приложения, но еще не соединили их вместе. У нас есть два варианта для этого. Либо мы связываем объекты вместе, используя простую Java, либо используем какое-то решение для внедрения зависимостей.

7.1. Внедрение конструктора

Поскольку все является простым объектом Java, и поскольку мы создали класс ExecutionContext для координации построения, мы можем выполнять всю работу в его конструкторе.

Мы могли бы ожидать расширения конструктора для сборки всех bean-компонентов по порядку:

this.config = ... // load config
this.toDoApi = ... // build api
this.postApi = ... // build post API
this.toDoReaderService = new ToDoReaderService(toDoApi);
this.postService = new PostService(postApi);

Это самое простое решение. Он поощряет использование четко определенных компонентов, которые можно тестировать и которые легко компоновать во время выполнения.

Однако при превышении определенного количества компонентов это становится многословным и сложным в управлении.

7.2. Добавьте инфраструктуру внедрения зависимостей

DropWizard использует Guice для внедрения зависимостей. Эта библиотека относительно небольшая и может помочь в управлении компонентами в AWS Lambda.

Добавим его зависимость :

<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>5.0.1</version>
</dependency>

7.3. Используйте инъекцию там, где это легко

Мы можем аннотировать bean-компоненты, созданные из других bean-компонентов, с помощью аннотации @Inject , чтобы сделать их автоматически внедряемыми :

public class PostService {
private PostApi postApi;

@Inject
public PostService(PostApi postApi) {
this.postApi = postApi;
}

// other functions
}

7.4. Создание пользовательского модуля внедрения

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

public class Services extends AbstractModule {
@Override
protected void configure() {
Config config =
ConfigLoader.loadYmlConfigFromResource("configuration.yml", Config.class);

ToDoApi toDoApi = Feign.builder()
.decoder(new GsonDecoder())
.logger(new Slf4jLogger())
.logLevel(FULL)
.requestInterceptor(... // omitted
.target(ToDoApi.class, config.getToDoEndpoint());

PostApi postApi = Feign.builder()
.encoder(new GsonEncoder())
.logger(new Slf4jLogger())
.logLevel(FULL)
.requestInterceptor(... // omitted
.target(PostApi.class, config.getPostEndpoint());

bind(Config.class).toInstance(config);
bind(ToDoApi.class).toInstance(toDoApi);
bind(PostApi.class).toInstance(postApi);
}
}

Затем мы используем этот модуль внутри нашего ExecutionContext через инжектор :

public ExecutionContext() {
LOGGER.info("Loading configuration");

try {
Injector injector = Guice.createInjector(new Services());
this.toDoReaderService = injector.getInstance(ToDoReaderService.class);
this.postService = injector.getInstance(PostService.class);
} catch (Exception e) {
LOGGER.error("Could not start", e);
}
}

Этот подход хорошо масштабируется, поскольку он локализует зависимости компонентов для классов, ближайших к каждому компоненту. С центральным конфигурационным классом, создающим каждый bean-компонент, любое изменение в зависимости всегда требует изменений и там.

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

7.5. Использование объектов вместе

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

@Override
public void handleRequest(InputStream inputStream,
OutputStream outputStream, Context context) throws IOException {

PostService postService = executionContext.getPostService();
executionContext.getToDoReaderService()
.getOldestToDo()
.ifPresent(postService::makePost);
}

Давайте проверим это:

$ sam build
$ sam local invoke

Mounting /Users/ashleyfrieze/dev/tutorials/aws-lambda/todo-reminder/.aws-sam/build/ToDoFunction as /var/task:ro,delegated inside runtime container
2021-05-23 22:29:43 INFO ExecutionContext - Loading configuration
2021-05-23 22:29:44 INFO ToDoReaderService - ToDo Endpoint on: https://jsonplaceholder.typicode.com
App starting
Environment: dev
2021-05-23 22:29:44 73264c34-ca48-4c3e-a2b4-5e7e74e13960 INFO PostService - Posting about: ToDoItem{userId=1, id=1, title='delectus aut autem', completed=false}
2021-05-23 22:29:44 73264c34-ca48-4c3e-a2b4-5e7e74e13960 INFO PostService - Post: PostItem{title='To Do is Out Of Date: 1', body='Not done: delectus aut autem', userId=1}
END RequestId: 73264c34-ca48-4c3e-a2b4-5e7e74e13960

8. Заключение

В этой статье мы рассмотрели важность таких функций, как настройка и ведение журнала, при использовании Java для создания AWS Lambda корпоративного уровня. Мы видели, как такие фреймворки, как Spring и DropWizard, предоставляют эти инструменты по умолчанию.

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

Затем мы рассмотрели библиотеки для загрузки конфигурации, создания клиента REST, маршалинга данных JSON и связывания наших объектов вместе, сосредоточившись на выборе меньших библиотек, чтобы наша Lambda запускалась как можно быстрее.

Как всегда, пример кода можно найти на GitHub .