有时我们的服务需要与难以在本地复制以进行开发或运行测试的基础设施集成,AWS 就是一个例子。

对于测试,有时模拟足以解决问题。

但是,要在本地测试我们的服务,我们应该使用我们的 AWS 凭证并连接到真实环境吗?还有......团队的其他成员?

在这篇文章中,我们将了解 LocalStack 是什么,我们将使用它来运行服务以在本地 S3 中存储文件。

LocalStack在座右铭 “功能齐全的本地 AWS 云堆栈” 下可能是我们使用 AWS SDK 进行某些开发所需的工具。

它有一个免费版本,允许我们针对 S3、SQS、DynamoDb 等服务进行开发......

还有另一个付费版本,提供 CloudFront、Neptune 等服务......

查看各版本服务详情...

举个例子(和代码🤓),假设我们需要一个 API 来发布我们稍后将用于在 Web 环境中提供它们的图像。

这是使用 S3 存储桶作为存储系统和 CloudFront作为恢复系统的理想用例。

所以...让我们试试 LocalStack 💻🚀

注意:CloudFront 是 LocalStack 付费分发服务的一部分,因此我们将无法在不付费的情况下在本地对其进行测试......虽然......我们确实知道 S3 存储桶最终如何链接为 CloudFront 分发的来源以及它如何影响 URL 以检索存储在 S3 中的文件,我们将会发生一些事情:)

示例

GitHub 徽标alextremp/s3-storage-service-demo

AWS S3 存储和本地配置 LocalStack

该项目是一篇文章的演示,地址为dev.to/alextremp

S3桶文件存储服务

  • 将文件发送到特定路径的 POST 端点。

  • 服务会将文件存储到 S3 存储桶。

  • 将返回文件的访问 URL,无论是本地、S3 还是 CloudFront(如果可用)。

用法

示例端点将在http://localhost:8080/storage上公开 POST 接受form-data,其中:

  • file:我们要保存在 S3 中的文件

  • path:我们要保存文件的路径,相对于存储桶

针对 LocalStack 的本地执行

码头工人组成 -d

./gradlew bootRun

进入全屏模式 退出全屏模式

针对真实存储桶的本地执行

活动配置文件设置为生产

PROFILEu003d"--spring.profiles.activeu003dpro"

您的 AWS 访问密钥

ACCESSu003d"--s3-storage.accessKeyu003d"

你的 AWS 密钥

SECRETu003d"--s3-storage.secretKeyu003d"

您的 AWS S3 存储桶

...

进入全屏模式 退出全屏模式

在 GitHub 上查看

Stack:

java, spring-boot, aws-sdk, testcontainers

进入全屏模式 退出全屏模式

使用 docker-compose 提升 S3

让我们配置 localstack 服务:

docker-compose.yml

version: '3.5'

services:
  s3-storage:
    image: localstack/localstack:0.12.5
    environment:
      # permite más servicios separados por comas
      - SERVICES=s3 
      - DEBUG=1
      - DEFAULT_REGION=eu-west-1
      - AWS_ACCESS_KEY_ID=test
      - AWS_SECRET_ACCESS_KEY=test
    ports:
      # localstack usa rango de puertos, para el ejemplo,
      # usaremos solo el de S3, mapeado en local a 14566 en un 
      # fichero docker-compose.override.yml para permitir 
      # tests con puerto dinámico
      - '4566'
    volumes:
      # inicializaremos un bucket aquí
      - './volumes/s3-storage/.init:/docker-entrypoint-initaws.d'
      # no versionado, localstack nos generará aquí el .pem 
      # para nuestras claves de acceso fake
      - './volumes/s3-storage/.localstack:/tmp/localstack'

进入全屏模式 退出全屏模式

我们可以通过启动 docker-compose 来生成一个桶:

./volumes/s3-storage/.init/buckets.sh

#!/bin/bash
aws --endpoint-url=http://localhost:4566 s3 mb s3://com.github.alextremp.storage

进入全屏模式 退出全屏模式

运行docker-compose up时,我们应该看到 localstack 映像的 AWS 客户端已经生成了我们在初始化脚本中指示的存储桶:

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--oF4sQFVy--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/ire48awxbjyrurfdeod3.png)

与桶交互

对于这篇文章,我只想解释示例代码的几个特定点,以便我们了解如何使用 LocalStack 使其在本地工作,并在 AWS 的生产环境中工作。

以 AWS SDK for S3 将用于保存文件的存储库作为参考(实际上是在多部分 POST 中接收到的资源的 InputStream ):

  public S3ResourceRepository(S3Client s3Client,
                              S3ResourceRepositoryOptions repositoryOptions,
                              DestinationFactory destinationFactory) {
    this.s3Client = s3Client;
    this.repositoryOptions = repositoryOptions;
    this.destinationFactory = destinationFactory;
  }

  @Override
  public Destination save(StreamableResource streamableResource, ResourceOptions resourceOptions) {
    PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
          // the target bucket
          .bucket(repositoryOptions.getBucket())
          // the target path in the bucket
          .key(resourceOptions.getPath());

    // setting the content-type makes it web-friendly when being read
    requestBuilder.contentType(URLConnection.guessContentTypeFromName(resourceOptions.getPath()));
    // it's highly recommendable to specify the cache-control for presupposed repetitive reads, specially if it's combined with CloudFront
    requestBuilder.cacheControl(String.format("public, max-age=%s", resourceOptions.getMaxAge()));

    if (!repositoryOptions.hasOriginReference()) {
      // when needed to be read directly from S3, no need if it's a bucket linked to CloudFront
      requestBuilder.acl(ObjectCannedACL.PUBLIC_READ);
    }

    PutObjectRequest objectRequest = requestBuilder.build();
    s3Client.putObject(objectRequest, RequestBody.fromInputStream(streamableResource.stream(), streamableResource.contentLength()));

    return destinationFactory.create(resourceOptions.getPath());
  }

进入全屏模式 退出全屏模式

实际上,要让服务在本地和生产环境中发挥作用(即使我们在云中有一个中间开发或预生产环境),最有趣的是我们可能需要为存储库修改选项以在每个环境中工作。

@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Accessors(chain = true)
@NoArgsConstructor
public class S3ResourceRepositoryOptions {
  String region;
  String accessKey;
  String secretKey;
  String bucket;
  String endpoint;
  String originFor;

  public Boolean hasCustomEndpoint() {
    return StringUtils.isNotBlank(endpoint);
  }

  public Boolean hasOriginReference() {
    return StringUtils.isNotBlank(originFor);
  }
}

进入全屏模式 退出全屏模式

S3 端点覆盖

# application-dev.yml
s3-storage:
  endpoint: http://localhost:14566

进入全屏模式 退出全屏模式

关于选项hasCustomEndpoint,在本地,既要能够在保存文件时与 S3 通信(默认输入s3://BUCKET/PATH),又要能够通过 HTTP 检索(默认输入https://s3-REGION.amazonaws.com/BUCKET/PATH),我们需要修改端点在 LocalStack 中公开 S3 并在服务中使用它与它进行通信。

  • 在 LocalStack 中,我们已经在 docker-compose 的初始化脚本中使用aws --endpoint-url=http://localhost:4566 ...完成了它

  • 创建 S3 客户端时,我们必须使用选项endpointOverride绑定它:

@Configuration
@ComponentScan("com.github.alextremp.storage.infrastructure.aws")
public class AwsConfiguration {

  @Bean
  @ConfigurationProperties(prefix = "s3-storage")
  public S3ResourceRepositoryOptions s3ResourceRepositoryOptions() {
    return new S3ResourceRepositoryOptions();
  }

  @Bean
  public S3Client s3Client(S3ResourceRepositoryOptions s3ResourceRepositoryOptions) {
    S3ClientBuilder s3ClientBuilder = S3Client.builder()
          .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(
                s3ResourceRepositoryOptions.getAccessKey(),
                s3ResourceRepositoryOptions.getSecretKey()
          )))
          .region(Region.of(s3ResourceRepositoryOptions.getRegion()));

    if (s3ResourceRepositoryOptions.hasCustomEndpoint()) {
      s3ClientBuilder.endpointOverride(URI.create(s3ResourceRepositoryOptions.getEndpoint()));
    }
    return s3ClientBuilder.build();
  }
}

进入全屏模式 退出全屏模式

这将允许我们在 localhost 上正确保存文件:

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--5ROPP3sR--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/2b81ere8ks6vgmlv0bw3.png)

以及通过 HTTP 检索它们:

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--Pvn8cUth--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/pxoz0ysxca0f3az8g679.png)

在 CloudFront 中为 PROD 启用输出

如果我们要使用此服务与真实存储桶进行交互,而不使用 CloudFront,例如:

# application-pro.yml
s3-storage:
  bucket: a.dcdn.es

  # add the accessKey and secretKey config...

进入全屏模式 退出全屏模式

我们需要生成的 URL 才能通过 HTTPS 访问 S3 中的相同资源,如下所示:

https://s3-eu-west-1.amazonaws.com/a.dcdn.es/demo/demo-image.png

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--CwG9A3Pc--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/6xiu8jikyiet6uox57jz.png)

但是,如果我们将该存储桶映射为 CloudFront 分配中的源,例如:

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--al2nGzX9--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/44rwpsqfjb6o1hnfy87x.png)

虽然我们无法在 LocalStack 上测试 CloudFront,但我们可以配置服务以生成必要的 URL 以通过 CloudFront 输出:

# application-pro.yml
s3-storage:
  bucket: a.dcdn.es
  originFor: a.dcdn.es

  # add the accessKey and secretKey config...

进入全屏模式 退出全屏模式

[图像](https://res.cloudinary.com/practicaldev/image/fetch/s--1Q0phvP2--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads .s3.amazonaws.com/uploads/articles/ar4oy3uhd57ezs80flai.png)

对于这种情况,并以代码示例结束,我们只需要我们的服务能够根据存储的属性生成 URL:

@Component
public class AwsDestinationFactory implements DestinationFactory {

  private static final String HTTPS_PROTOCOL = "https://";
  private static final String AWS_S3_DOMAIN_TEMPLATE = "s3-%s.amazonaws.com";

  private final S3ResourceRepositoryOptions options;

  public AwsDestinationFactory(S3ResourceRepositoryOptions options) {
    this.options = options;
  }

  @Override
  @SneakyThrows
  public Destination create(String path) {
    StringBuilder rootBuilder = new StringBuilder();
    if (options.hasOriginReference()) {
      rootBuilder.append(HTTPS_PROTOCOL)
            .append(options.getOriginFor());
    } else if (options.hasCustomEndpoint()) {
      rootBuilder.append(options.getEndpoint())
            .append(Destination.SEPARATOR)
            .append(options.getBucket());
    } else {
      rootBuilder.append(HTTPS_PROTOCOL)
            .append(String.format(AWS_S3_DOMAIN_TEMPLATE, options.getRegion()))
            .append(Destination.SEPARATOR)
            .append(options.getBucket());
    }
    return new Destination(rootBuilder.toString(), path);
  }
}

进入全屏模式 退出全屏模式

🚀🚀

结论

从我的角度来看,当生产中的代码无法在本地重现/在测试中无法自动化执行时,我们有两个选择:让它在本地重现,或者让我们拭目以待😬

但是由于我们需要手指输入,所以LocalStack与Docker Compose和Test Containers可以帮助我们在与 AWS 服务集成时解决本地执行的基础设施问题,所以我们我们可以专注于代码而不是执行环境

如果有一天你用 S3 做某事,你可以自由复制粘贴你需要的东西 :)

GitHub 徽标alextremp/s3-storage-service-demo

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐