JPAでバイナリファイルをS3に永続化する

はじめに

アップロードされたファイルなどのバイナリデータを、JPA の上で、透過的に S3 に永続化する実装例です。

JPA でバイナリデータを扱う場合、@Lob で BLOB として扱うことができますが、データベース容量などを考えた場合、データベース外のストレージに永続化したいケースがあります。

JPA では、@EntityListener により、データベースへのアクセスにフックすることができるので、これを利用することで、S3 などの外部ストレージサービスへの永続化を、利用側から透過的に実施することができます。


FileObject

バイナリファイルを表現する FileObject エンティティを用意します。 画面へのファイル名やリンクなどの表示は、このエンティティを介して行います。

@Entity
public class FileObject {

    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    private String fileName;
    private String contentType;
    private Long fileSize;
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    private StorageStoreEntity storeEntity;

    public static FileObject of(Path path) {
        var fileObject = new FileObject();
        fileObject.setFileName(path.getFileName());
        ...
        fileObject.storeEntity(new StorageStoreEntity(fileObject, Files.readAllBytes(path)));
    }

    public final byte[] getBytes() {
        return (storeEntity == null) ? null : storeEntity.getCarrier();
    }
    // ...

バイナリの実態は StorageStoreEntity として扱うこととし、FetchType.LAZY とします。


StorageStoreEntity

バイナリ自体を表す StorageStoreEntity は以下のように定義します。

@Entity
@EntityListeners({ S3StorageStoreEntityListener.class })
public class StorageStoreEntity {

    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    private String domain;
    private String path;
    private String hash;
    @Transient
    private byte[] carrier;
    
    protected StorageStoreEntity() { }
    
    private StorageStoreEntity(String domain, String path, byte[] carrier) {
        this.domain = domain;
        this.path = path;
        setCarrier(carrier);
    }
    
    public StorageStoreEntity(FileObject file, byte[] bytes)
        this("my-s3-bucket-name",
             file.getClass().getSimpleName() + "/" + UUID.randomUUID().toString() + "_" + escapedName(file.getFileName())),
             bytes);
    }
    
    public final void setCarrier(byte[] carrier) {
        this.carrier = carrier;
        this.hash = ArrayUtils.isEmpty(carrier) ? "" : DigestUtils.sha1Hex(carrier);
    }
    
    public byte[] getCarrier() {
        return carrier;
    }
    
    public void bindCarrier(byte[] carrier) {
        if (!DigestUtils.sha1Hex(carrier).equals(getHash())) {
            throw new ValidationException();
        }
        this.carrier = Objects.requireNonNull(carrier);
    }

    private static String escapedName(String name) {
        return name.replaceAll("[\\p{Cntrl}\\p{Space}]", "")
            .replaceAll("[\\p{Punct}&&[^!\\-_.*'()]]", "-"); // Safe special characters
    }
    // ...
}

バイト配列を carrier としてデータ移送用に@Transient で定義します。 バイト配列は永続化対象外となるため、JPAにおけるダーティーチェック、ならびにS3から取得したバイナリデータの正当性を確認するため、sha1 のハッシュを hash として持ちます(このぐらいの用途であれば sha1 程度で十分でしょう)。

S3への保存は、キー名を元に行いますが、ファイル名は重複する可能性があるため、プレフィックスに randomUUID を付与します。加えて、ファイル名は、安全な名前にエスケープ(escapedName)しています。


S3StorageStoreEntityListener

StorageStoreEntity にはエンティティ・リスナを定義し、バイナリファイルの永続化操作を、carrier を介して行います。

public class S3StorageStoreEntityListener {

    @Inject
    private S3Client s3Client;
    
    @PostLoad
    public void postLoad(StorageStoreEntity entity) {
        byte[] bytes = s3Client.getObjectAsBytes(req ->
                req.bucket(entity.getDomain()).key(entity.getPath())).asByteArray();
        entity.bindCarrier(bytes);
    }
    
    @PostPersist @PostUpdate
    public void postPersist(StorageStoreEntity entity) {
        s3Client.putObject(req ->
                req.bucket(entity.getDomain()).key(entity.getPath()),
                RequestBody.fromBytes(entity.getCarrier()));
    }
    
    @PostRemove
    public void postRemove(StorageStoreEntity entity) {
        s3Client.deleteObject(req ->
                req.bucket(entity.getDomain()).key(entity.getPath()));
    }
}

各イベントのタイミングで、S3Client で処理しているだけです。

S3Client は CDI @Produces で供給しておけば良いでしょう。

@ApplicationScoped
public class StorageStoreSupport {
    
    @Produces
    private S3Client s3Client = s3Client();
    
    private S3Client s3Client() {
        String accessKeyId = ...
        String secretAccessKey = ...
        return S3Client.builder()
                .region(Region.AP_NORTHEAST_1)
                .credentialsProvider(StaticCredentialsProvider
                        .create(AwsBasicCredentials.create(accessKeyId, secretAccessKey)))
                .build();
    }
}


まとめ

以上で、以下のようにファイルストレージサービスなどの存在を意識することなく、バイナリファイルを扱うことができるようになります。

var fileObject = FileObject.of(path); 
em.persist(fileObject);
var fileObject = em.find(FileObject.class, id);
fileObject.getBytes();