Spring Bootアプリケーションで画像の保存・表示を行う

# はじめに
Spring Bootアプリケーションで画像の保存・表示を行う。

# 環境
#### 開発環境は以下の通り。
Eclipse IDE Version: 2023-12 (4.30.0)
PostgreSQL 16.0,

#### 関連記事
[Spring Securityでログイン機能を実装する](https://qiita.com/KuboYoshinori/items/07837942161bb5bd4144)

[Spring Bootで検索と一覧表示を行う](https://qiita.com/KuboYoshinori/items/deb11fe0287aeeef3dfb)

[Spring Bootアプリケーションでページネーションを実装する](https://qiita.com/KuboYoshinori/items/3f1e6e80cb78371d0bb5)


##### サンプルソース
[Git Hub](https://github.com/bond-baga/demo_Postgre/releases/tag/demo_Postgre)

#### アプリケーションプロパティ
関連記事と同じ。

#### build.gradle
関連記事のbuild.gradleにimgscalrライブラリを追加している。（下記参照）

# サンプルプログラム
#### 一覧
以下のアドレスにアクセスするとログイン画面が表示される。
http://localhost:8080

ログイン完了後、一覧画面に遷移すると以下のように画像付き社員一覧が表示される。
画像がない社員の編集リンクをクリックする。
（新規登録の場合も同様）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/d68a36ee-0998-4f10-a81d-393557366cfb.png)

編集画面が表示される。
「ファイルを選択」ボタンをクリックして画像ファイルを選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/d1fec08b-4e65-48d9-8116-fcc07d503977.png)

選択された画像が表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/e0d1e2e7-1f23-4b02-9457-52add0e7a860.png)

登録ボタンをクリックする。
編集画面のデータが登録されて一覧に戻る。
一覧に選択した画像が表示されている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/596c7018-9873-4557-b33f-acaab454179a.png)

# サンプルの解説
* 画像はデータベースのpostgreSQLのbytea型に格納される
* 画像はJPEGとPNGに対応している
* bytea型に保存する際、画像のサイズを調整している

###### 画像処理で使用するインターフェイス・ライブラリ・変換方法
:::note info
MultipartFile
MultipartFileは、Spring Frameworkにおけるファイルアップロードの処理で使用されるインターフェースです。主に、Webフォームからアップロードされたファイルデータを扱う際に利用されます。
:::
本サンプルではHTMLのタグ「input type="file"」でアップロードファイルを指定する。「th:field」でMultipartFile型の変数を指定してコントローラー側で処理する。

:::note info
imgscalr
参考記事：Java で画像のサイズを変更する方法: imgscalr とその機能のガイド
https://www.php.cn/ja/faq/1796659824.html
imgscalr は、画像のスケーリング用に特別に設計された人気のあるオープンソース Java ライブラリです。ユーザーフレンドリーなインターフェイスと複数の最適化アルゴリズムから選択できます。
:::
本サンプルではサービスクラスの画像の拡大・縮小で使用している。JPEGとPNGの変換も可能。build.gradleでライブラリを参照が必要。

:::note info
base64
参考記事：base64ってなんぞ？？理解のために実装してみた
https://qiita.com/PlanetMeron/items/2905e2d0aa7fe46a36d4
メールを使って画像や音声などのデータをやりとりしたいと思った時に、英数字しか対応していないSMTPでは、それらのデータを送受信することができませんでした。
そこで、すべてのデータを英数字で表すMIME(Multipurpose Internet Mail Extensions)という規格が登場し、その中でbase64というデータの変換方法が定められました。
:::
画像（バイナリーデータ）をHTMLで表示するための変換方法で本サンプルではサービスクラスのバイナリーデータのエンコードで使用している。

###### DB
職員情報(membersテーブル)と画像情報(imageテーブル)のレイアウト。
~~~ table.sql 
create table public.members (
  code character varying(50) not null
  , name character varying(100)
  , kana character varying(100)
  , statecode integer
  , statuscode integer
  , divisionname character varying(256)
  , remarks character varying(256)
  , primary key (code)
);

create table public.image (
  code character varying(50) not null
  , image bytea
  , contenttype character varying(50)
  , primary key (code)
);
~~~

###### imageテーブルのimageとcontenttypeについて
* imageカラムは画像のバイナリーデータを格納
* contenttypeはファイルの種類を格納
* なぜファイルの種類が必要か？
ブラウザで確認するとエンコード前に「data:image/jpeg;base64,」の文字列が付加されている。この「image/jpeg」（またはimage/png」を格納しているのがcontenttypeカラムになる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/7e78cef2-5b6c-4bc2-8074-bce9479362f6.png)

###### build.gradle
本サンプルでは画像処理のライブラリとしてimgscalrを使用しているのでbuild.gradleで依存関係の設定が必要。

~~~ build.gradle
dependencies {
-- 省略 --	
	implementation 'org.imgscalr:imgscalr-lib:4.2' // Use the latest version
}
~~~

###### DTO
画像に関係するのはファイルのアップロード用のMultipartFile、画像表示の際のファイル種類とエンコードされた画像データ。
~~~ UserForm.java
@Data
public class UserForm implements Serializable{
-- 省略 --
    // 写真
	private MultipartFile imgfile;
	private String contenttype;
	private String imgString;
-- 省略 --	
	public UserForm() {
		imgfile = null;
		contenttype = "";
		imgString = "";
-- 省略 --    		
	}
}
~~~

###### edit.html
~~~ edit.html
<!DOCTYPE html>
<html xmlns="ctp://www.w3.org/1999/xhtml" 
      xmlns:th="https://www.thymeleaf.org"
      class="edit">
-- 省略 -- 
	
<body>
<form class="center" id="form" method="post" th:object="${UserForm}" th:action="@{/update_write}" enctype="multipart/form-data">	
 	<input type="hidden" th:field=*{mapstring}>
 	<input type="hidden" th:field=*{imgString}>

	<div class="slightly-left">
	<th:block th:if="${not UserForm.msg.isEmpty()}">
	    <th:block th:each="item:*{msg}" th:object="${item}">
			<label class="error" th:text="${item.value}"></label><br>		
		</th:block>
	</th:block>
	<h3>職員名簿</h3>     
	</div>
	<th:block th:if="${title=='更新'}">
	<table class="table-center">
		<tr>
            <td>コード</td>
            <td><input type="text" th:field=*{code} readonly required/></td>			
		</tr>
		<tr>
            <td>氏名</td>
            <td><input type="text" th:field=*{name} required/></td>						
		</tr>
		<tr>
            <td>カナ</td>
            <td><input type="text" th:field=*{kana} required/></td>						
		</tr>
		<tr>
            <td>状態</td>
            <td>
    			<select th:field="*{statecode}">
					<option th:value="0"></option>
					<option th:each="item:*{mapitems.get('state')}" th:value="${item.key}" th:text="${item.value}" th:selected="(${item.key}==*{statecode})"></option>		
    			</select>
			</td>
		</tr>
		<tr>
            <td>雇用形態</td>
            <td>
    			<select th:field="*{statuscode}">
					<option th:value="0"></option>
					<option th:each="item:*{mapitems.get('status')}" th:value="${item.key}" th:text="${item.value}" th:selected="(${item.key}==*{statuscode})"></option>		
    			</select>
			</td>  									
		</tr>
		<tr>
            <td>部署</td>
            <td><input type="text" th:field=*{divisionname} required/></td>					
		</tr>
		<tr>
            <td>写真</td>
            <td rowspan="2"><img th:src=*{imgString} style="width: 100px;"/></td> 			
		</tr>
		<tr>
            <td><input type="file" th:field=*{imgfile} onchange="previewFile()" /></td>		
		</tr>
        <tr>
            <td></td>
            <td>
				<button type="button" onclick="history.back()">戻る</button>
    			<button type="button" onclick="updateclick()">登録</button>
   				<button type="reset" onclick="resetclick()">Reset</button>
            </td>
        </tr>		
	</table>
	</th:block>	
</form>

<script th:inline="javascript">
-- 省略 --
function updateclick() {
	let result = confirm('編集内容を登録しますか？');
	if(!result) return;
		
    let form = document.getElementById('form');
  	form.action = "/update_write";
    form.submit();	
}

function previewFile() {
	const allowedFileTypes = ["image/png", "image/jpeg"];
  	const preview = document.querySelector("img");
  	//const file = document.querySelector("input[type=file]";).files[0];
  	const file = document.getElementById("imgfile").files[0];
  	if (file.length == 0) {
    	alert("ファイルサイズが０バイトです。");
    	document.getElementById("imgfile").value = "";
    	return;
  	}  	  	
	if (! allowedFileTypes.includes(file.type)){
    	alert("画像ファイルではありません。");
    	document.getElementById("imgfile").value = "";
    	return;		
	}  	  	
  	const reader = new FileReader();
	reader.addEventListener("load",() => {
    	preview.src = reader.result;
    },
    	false,
  	);
  	if (file) {
    	reader.readAsDataURL(file);
  	}
}
</script>
</body>
</html>
~~~


* 画像表示
imgタグのsrcにバイナリーデータをエンコードした文字列を設定する。
~~~
<img th:src=*{imgString} style="width: 100px;"/>
~~~

* 画像選択時のイベント
アップロードファイルが選択されるとJavascriptのpreviewFile()メソッドが起動されて画像ファイルが編集画面に表示される。

~~~
HTML
<input type="file" th:field=*{imgfile} onchange="previewFile()" />
↓
Javascript
function previewFile() {
  	const preview = document.querySelector("img");
  	const file = document.getElementById("imgfile").files[0];
    --　ファイル　チェック --　
    const reader = new FileReader();
	reader.addEventListener("load",() => {
    	preview.src = reader.result;
    },
    	false,
  	);
  	if (file) {
    	reader.readAsDataURL(file);
  	}
}
~~~

:::note info
FileReader.readAsDataURL()
readAsDataURL メソッドは、指定されたBlob または File の内容を読み込むために使用されます。
読み込み操作が終了すると、readyState が DONE となり、loadend が発生します。
このときresult 属性には、ファイルのデータを表す、base64 エンコーディングされた data: URL の文字列が格納されます。
:::

###### 登録ボタンクリック
edit.htmlは新規・編集・削除の各モードで使用される。編集モードで登録ボタンクリックの場合はJavascriptのupdateclick()イベントが発生してコントローラーの"/update_write"にマッピングされたPostメソッドが起動する。

###### コントローラー
コントローラーの"/update_write"にマッピングされたpostupdate_write()メソッドではUserServiceのupdate()メソッドを呼び出してデータの更新を行う。
データ更新完了後は"/list"にマッピングされたGetメソッドにリダイレクトする。現状は登録時のフォームが検索条件として渡されるので更新データのみが出力されている。
~~~ UserController.java
@Controller
public class UserController {
    @Autowired
    UserService userService;
        
    @Autowired
    LoginService loginService;
    
    @Autowired
    NameService nameService;

	@Autowired
    ObjectMapper mapper;
	
	@GetMapping(value={"/list"})
    public String getList(@ModelAttribute("UserForm") UserForm form, Model model, HttpSession session) {
    	model.addAttribute("title", "一覧"); 
        setMapItems(form);
        setSearchKey(form, session);
        // 職員データの取得
        Map<String, List<Map<String, Object>>> data = new HashMap<>();
        Integer count = userService.count(form);
        form.setCount(count);
        form.setMaxpage((int) (Math.floor((count - 1) / 10) + 1));
        if (form.getPage() == 0) form.setPage(1);
        data.put("users", userService.select(form));
        model.addAttribute("data", data);
        model.addAttribute("UserForm", form);
        return "list";
    }
        
    //更新
    @PostMapping("/update_write")
    public String postupdate_write(UserForm form, BindingResult result, Model model, RedirectAttributes redirectAttributes) {
    	// 変更後の一覧で変更データを表示するために変更後のデータの状態と雇用形態を検索条件に設定する。
    	form.setStatekeys(new ArrayList<Integer>(Arrays.asList(form.getStatecode())));
    	form.setStatusKey(form.getStatuscode());
    	redirectAttributes.addFlashAttribute("UserForm", form);
    	if (form.getName()=="") {
        	form.getMsg().put("name", "氏名が未入力です。");  
    	}
    	if (form.getKana()=="") {
        	form.getMsg().put("kana", "カナが未入力です。");  
    	}
		if (!form.getMsg().isEmpty()) {
        	return "redirect:/update";    		
    	}    

		try {
    		userService.update(form);
    	} catch(Exception e) {
    		form.getMsg().put("error", "登録に失敗しました。\nエラー："+e.toString());
            return "redirect:/update";    		
    	}
    	form.setSearchname(form.getName());
    	form.setSearchkana(form.getKana());
    	return "redirect:/list";
    } 
}
~~~

###### サービス
データの更新処理は以下の通り。
* DTO(UserForm)からMultipartFileを取得
* MultipartFileを引数にfiledata()メソッドを呼び出す
  byte[] bytes = filedata(multipartFile);
* filedata()メソッドではMultipartFileからBufferedImageを作成する
　BufferedImage originalImg = ImageIO.read(multipartFile.getInputStream());
* 取得したBufferedImageをリサイズする
　BufferedImage resizeImg = Scalr.resize(originalImg, Scalr.Method.QUALITY, Scalr.Mode.FIT_EXACT, resizeW, resizeH);
* リサイズしたBufferedImageをファイル種類を指定してByteArrayOutputStreamに書き込む
　ImageIO.write(resizeImg, formatName, baos);
* ByteArrayOutputStreamのバイト配列を返す
* 戻り値のバイト配列をSqlBinaryValue型に変換する
　SqlBinaryValue data = new SqlBinaryValue(bytes);
* 画像データを変換してbytea型に書き込む 



~~~ UserService.java
@Service
public class UserService {
	
	@Autowired
	private JdbcTemplate jdbcTemplate;
	
	LobHandler lobHandler = new DefaultLobHandler(); 
	
	public Boolean update(UserForm form) throws Exception{
    	String query = "UPDATE members SET"
		+ " name = '" + form.getName() + "',"
		+ " kana = '" + form.getKana() + "',"
        + " stateCode = " + form.getStatecode().toString() + ","
        + " statusCode = " + form.getStatuscode().toString() + ","
        + " divisionname = '" + form.getDivisionname() + "'"
        + " WHERE code = '"  +  form.getCode() + "'";
		jdbcTemplate.update(query); 
		MultipartFile multipartFile = form.getImgfile();
		if (!multipartFile.isEmpty()) {
			byte[] bytes = filedata(multipartFile);
        	SqlBinaryValue data = new SqlBinaryValue(bytes);
        	String contenttype = multipartFile.getContentType();
        	query = "DELETE FROM image WHERE code = ?";        	
        	jdbcTemplate.update(query, new Object[] {form.getCode()});
        	query = "INSERT INTO image (code, image, contentType) VALUES (?,?,?)";  
        	jdbcTemplate.update(query, new Object[] {form.getCode(), data, contenttype});
        }else if (form.getImgString().equals("")) {
        	query = "DELETE FROM image WHERE code = ?";        	
        	jdbcTemplate.update(query, new Object[] {form.getCode()});        	
        }
		return true;
	}

    public byte[] filedata(MultipartFile multipartFile) throws Exception{
	    int resizeW;
	    int resizeH;		
	    double rateW = 1;
	    double rateH = 1;
	    double rate = 1;
		BufferedImage originalImg = ImageIO.read(multipartFile.getInputStream());
		if (originalImg.getWidth() > 200) {
			rateW = (double)200/originalImg.getWidth();
		}
		if (originalImg.getHeight() > 300) {
			rateH = (double)300/originalImg.getHeight();
		}
		rate = rateW < rateH ? rateW: rateH;
		
		resizeW = (int)Math.floor((double)originalImg.getWidth() * rate);
		resizeH = (int)Math.floor((double)originalImg.getHeight() * rate);
		BufferedImage resizeImg = Scalr.resize(originalImg, Scalr.Method.QUALITY, Scalr.Mode.FIT_EXACT, resizeW, resizeH);
		String formatName="png";
		if (multipartFile.getContentType().equals("image/jpeg")) formatName="jpeg";
		ByteArrayOutputStream baos = new ByteArrayOutputStream();
		ImageIO.write(resizeImg, formatName, baos);
		return baos.toByteArray();
	}	
}
~~~

:::note info
org.springframework.jdbc.core.support.SqlBinaryValue
Object to represent a binary parameter value for a SQL statement, e.g.a binary stream for a BLOB or a LONGVARBINARY or PostgreSQL BYTEA column. 
Designed for use with org.springframework.jdbc.core.JdbcTemplateas well as org.springframework.jdbc.core.simple.JdbcClient, to bepassed in as a parameter value wrapping the target content value. Can becombined with org.springframework.jdbc.core.SqlParameterValue forspecifying a SQL type, e.g. new SqlParameterValue(Types.BLOB, new SqlBinaryValue(myContent)).With most database drivers, the type hint is not actually necessary.
:::

# おわりに
ネットの記事を探りながら画像の保存と表示が一応はできた。最後のSqlBinaryValueなどは関連記事が少なかったが他にも方法はあるのだろう。