はじめに
アップロードされたファイルなどのバイナリデータを、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();