SpringBoot + MySQL 画像・動画登録の簡易メモ

SpringBoot で MySQL 使って画像・動画登録したときの簡易メモ
テーブルにBLOB型のカラム作って、アプリ・DBともに 1GB まで送れるように設定変更してやってく感じです
JPA 使わずに JDBCTemplate 使ってたりエンティティとフォームで同じクラス使ったりとごまかしてます．．．

# 大雑把な流れ

- 接続DB, ユーザ, テーブル作成
- プロジェクト作成
- DB・アプリともにデータ送信の上限設定
- 画像・動画のみ判定するバリデーション作成
- テーブルのレコードの値を持つDTO作成(フォームもこれ使う)
- コントローラ作成
- HTML作成

# 実行環境

- Windows11
- Pleiades 2024 Full Edition
- Maven v3.9.6
- pringFramework Boot v3.3.0
- Java v21
- spring-boot-starter-thymeleaf
- spring-boot-starter-web
- spring-boot-starter-data-jdbc
- spring-boot-starter-validation
- MySQL v8.0.28

# 接続DB, ユーザ, テーブル作成

接続するDB, ユーザ, テーブルを作成する
dbname, dbuser, dbpass は適当に好きな値に変えてOKです（後続はこの値を使用）

```sql
# DB作成
CREATE DATABASE dbname;
# ユーザ作成・権限追加
CREATE USER 'dbuser'@'localhost' IDENTIFIED BY 'dbpass';
GRANT ALL PRIVILEGES ON dbname.* TO 'dbuser'@'localhost';
# 使用DB設定
use dbname
# テーブル作成 content が画像・動画のバイナリ入るカラム
CREATE TABLE IF NOT EXISTS contentwk (
    id INT AUTO_INCREMENT,
    title VARCHAR(20),
    content_type VARCHAR(20),
    content MEDIUMBLOB,
    PRIMARY KEY(id)
);
```

# プロジェクト作成
Springスタータ・プロジェクト でプロジェクト作成する
依存関係は以下を設定する

- Spring Boot DevTools
- Spring Web
- Lombok
- Thymeleaf
- MySQL Driver
- Spring Data JDBC # 先のこと考えると Spring Data JPA で良いかも...
- Validation

あと DB 設定など `application.properties` の追加を以下に記載

```properties:application.properties
# datasource
spring.datasource.url=jdbc:mysql://localhost:3306/dbname
spring.datasource.username=dbuser
spring.datasource.password=dbpass
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# sql log
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=TRACE
```

# DB・アプリともにデータ送信の上限設定
両方とも設定を変更して1GBまで送れるようにしてます
DBは総計なのでファイルだけだと1GB無理かも．．．
他にもなんかしら設定変更が必要な可能性ありそうだな．．．

### DB (MySQL) 設定
```sql
SET GLOBAL max_allowed_packet=1073741824;
```

### アプリ (SpringBoot) 設定
```properties:application.properties
spring.servlet.multipart.max-file-size=1073741824
spring.servlet.multipart.max-request-size=1073741824
```

# 画像・動画のみ判定するバリデーション作成
MultipartFile に対してコンテンツタイプを判定するカスタムバリデーションを作成
コンテンツタイプは念のため渡せるようにしておく
バリデーションを作るときは以下の4つをやる
- アノテーションクラス
- バリデーションクラス
- メッセージプロパティ（デフォルト）
- メッセージプロパティ（日本語用）

### アノテーションクラス
prefixs にチェックするコンテンツタイプの接頭語(image, video, etc...)を渡せるようにする

```java:FileContents.java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Constraint(validatedBy = FileContentsValidator.class)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface FileContents {
	String message() default "{file.contents.message}";

	Class<?>[] groups() default {};

	Class<? extends Payload>[] payload() default {};

	String[] prefixs();
}
```

### バリデーションクラス
コンテンツタイプが prefixs で始まるやつ以外ならエラーにする

```java:FileContentsValidator.java
import java.util.Arrays;

import org.springframework.web.multipart.MultipartFile;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class FileContentsValidator implements ConstraintValidator<FileContents, MultipartFile> {

	private String[] prefixs;

	@Override
	public void initialize(FileContents annotation) {
		this.prefixs = annotation.prefixs();
	}

	@Override
	public boolean isValid(MultipartFile value, ConstraintValidatorContext context) {
		return value != null
				&& Arrays.asList(prefixs).stream().anyMatch(prefix -> value.getContentType().startsWith(prefix));
	}
}
```

### メッセージプロパティ（デフォルト）
application.properties と同じ場所に作る

```properties:ValidationMessages.properties
file.contents.message = MediaType is {prefixs}
```

### メッセージプロパティ（日本語用）
application.properties と同じ場所に作る

```properties:ValidationMessages_ja.properties
file.contents.message = コンテンツタイプが {prefixs} を選んでください
```

# テーブルのレコードの値を持つDTO作成(フォームもこれ使う)

テーブルのレコード、フォームの入力に合わせてクラス変数を設定
バイナリデータを img タグの src で使えるよにするエンコード処理も作っておく

```java:Contentwk.java
import java.util.Base64;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Contentwk {

    // テーブルのみ
	private Integer id;
	private String contentType;
	private byte[] content;
    // テーブル, 入力フォーム
    @NotBlank
	private String title;
    // 入力フォームのみ
    @FileContents(prefixs = { "image", "video" })
    private MultipartFile contentFile;

	// バイナリをエンコードする
    public String getContentEncoding() {
        return Base64.getEncoder().encodeToString(content);
    };

    // src で使えるよに文字列生成
    public String getSrc() {
        return "data:" + contentType + ";base64," + Base64.getEncoder().encodeToString(content);
    };

}

```


# コントローラ作成

登録・参照のアクションをつくる
`RedirectAttributes` 使ってバリデーション結果を設定してる

```java:ContentWkController.java
import java.io.IOException;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

@Controller
public class ContentWkController {

	@Autowired
	JdbcTemplate jdbcTemplate;

	@GetMapping("/contentwk")
	public String select(Model model) {
		String sql = "SELECT id, title, content_type, content FROM contentwk";
		RowMapper<Contentwk> rowMapperss = new BeanPropertyRowMapper<>(Contentwk.class);
		List<Contentwk> list = jdbcTemplate.query(sql, rowMapperss);
		model.addAttribute("list", list);
		model.addAttribute("errResult", model.asMap().get("errResult"));
		return "contentwk";
	}

	@PostMapping(value = "/contentwk/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
	public String upload(Model model, @Validated @ModelAttribute Contentwk contentwk, BindingResult result,
			RedirectAttributes attributes) throws IOException {
		if (result.hasErrors()) {
			attributes.addFlashAttribute("errResult", result);
			return "redirect:/contentwk";
		}

		String sql = "INSERT INTO contentwk (title, content_type, content) VALUES (?, ?, ?)";
		Object[] args = { contentwk.getTitle(), contentwk.getContentFile().getContentType(),
				contentwk.getContentFile().getBytes() };
		jdbcTemplate.update(sql, args);

		return "redirect:/contentwk";
	}
}
```

# HTML作成

CSS は BootStrap5 使って適当に設定
画像は丸枠、動画は四角枠で表示するようにする

```HTML:contentwk.html
<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    <title>Content Image Video Upload</title>
	<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
</head>
<body>
	<div class="container text-center">
		<h2 class="display-4 m-3">Contents Upload</h2>
		<form th:action="@{/contentwk/upload}" method="post" enctype="multipart/form-data">
			<div class="row m-3">
			  <div class="col-md-1">
			    <label for="title" class="col-form-label">タイトル</label>
			  </div>
			  <div class="col">
			    <input type="text" name="title" class="form-control" placeholder="title">
			  </div>
			</div>			
			<div class="row m-3">
				<div class="col-md-1">
				  <label for="content" class="col-form-label">コンテンツ</label>
				</div>
				<div class="col">
					  <input class="form-control" type="file" name="contentFile" accept="image/*, video/*">
				</div>
			</div>
			<button type="submit" class="btn btn-primary">Upload</button></td>
		</form>
		<div th:if="${errResult}" class="col-auto text-danger">
			<li th:each="err : ${errResult.fieldErrors}" th:text="${err.field + ':' + err.defaultMessage}"></li>
		</div>		
		<hr />

		<h2 class="display-4 m-3">Contents View</h2>
		<div class="row">
			<div class="col-lg-4 col-md-6 mb-3" th:each="content : ${list}">
				<th:block th:if="${#strings.startsWith(content.contentType, 'image')}">
                    <img class="mx-auto rounded-circle" width="300" height="300" th:src="${content.src}" loading="lazy" />
				</th:block>
				<th:block th:if="${#strings.startsWith(content.contentType, 'video')}">
					<video controls class="mx-auto" width="300" height="300" th:src="${content.src}" loading="lazy"></video>
				</th:block>
				<h4 class="fw-normal" th:text="${content.title}"></h4>
			</div>
		</div>
	</div>
		
	<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
</body>
</html>
```

# 実行確認

http://localhost:8080/contentwk で以下みたいになる
画像、動画アップロードできて表示する感じです

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51758/100da964-f3b2-7cea-e37c-8385da4945c3.png)