EasyMDEで画像ファイルをGCSにアップロードする

# 目次
[1. はじめに](#1-はじめに)
[2. EasyMDEとは](#2-easymdeとは)
[3. EasyMDEでの画像ファイルのアップロード方法](#3-easymdeでの画像ファイルのアップロード方法)
[4. EasyMDEから画像ファイルをアップロードする](#4-easymdeから画像ファイルをアップロードする)
&nbsp;&nbsp;&nbsp;&nbsp;[4.1. JavaScriptの解説](#41-javascriptの解説)
&nbsp;&nbsp;&nbsp;&nbsp;[4.2. HTMLの解説](#42-htmlの解説)
[5. 画像ファイルをWebサーバーで受信する](#5-画像ファイルをwebサーバーで受信する)
&nbsp;&nbsp;&nbsp;&nbsp;[5.1. Maven設定ファイル(pom.xml)の解説](#51-maven設定ファイルpomxmlの解説)
&nbsp;&nbsp;&nbsp;&nbsp;[5.2. Spring設定ファイル(application.properties)の解説](#52-spring設定ファイルapplicationpropertiesの解説)
&nbsp;&nbsp;&nbsp;&nbsp;[5.3. コントローラ(UploadImageForGcsController.java)の解説](#53-コントローラuploadimageforgcscontrollerjavaの解説)
[6. 完成！](#6-完成)
[7. おわりに](#7-おわりに)
[8. 参考にさせていただいた情報](#8-参考にさせていただいた情報)


# 1. はじめに

先日、Google Cloud Platform(GCP)のAppEngine(GAE)上に、あるパッケージ製品の環境を構築しました。そのパッケージ製品自体も素晴らしい出来なのですが、その製品の中で使われているマークダウンエディタ『EasyMDE』が特に素晴らしかったので（同時に機能実現にとても苦労したので）、そのお話をしたいと思います。  
具体的にはEasyMDEで画像ファイルをアップロードし、それをJavaプログラム(Spring Boot)で受け取って、Google Cloud Storage(GCS)に保存するという内容になります。なお、EasyMDEの画像ファイルアップロード処理はSpringやGCSに依存しているわけではないため、他の環境をお使いの方にも参考になるかと思います。

参考までに各製品は以下のバージョンを使っています。
* Java11
* Spring Boot 2.6.2
* Spring Cloud GCP(Storage) 3.1.0
* EasyMDE 2.8.0

:::note warn
**[注意] 画像ファイルのパス(URL)について** (2024/06/13 追記)
現在(2024/06/13 時点)の最新リリースバージョンの EasyMDE 2.18.0 で動作を確認したところ、このサンプルコードでアップロードした画像が EasyMDE のプレビュー画面で正常に表示されないことが分かりました。
EasyMDE 2.14.0 以降のプレビュー画面では、画像のパス(URL)にファイルの拡張子が付いていなければ、リンク(アンカー)として表示される仕様になっています。
なお、許可されている拡張子は以下のようです（バージョン 2.18.0 の場合）。
*png, jpg, jpeg, gif, svg, apng, avif, webp*

サンプルコードではパスに拡張子を付けていませんが、2.14.0 以降のバージョンの EasyMDE を使用する場合はパスに拡張子を付けるようにしてください。
:::


# 2. EasyMDEとは

[EasyMDE](https://github.com/Ionaru/easy-markdown-editor)はJavaScriptのオープンソースライブラリで、開発が停止したSimpleMDEをフォークして作られたマークダウンエディタです。  
記述したマークダウンをリアルタイムでプレビューでき、よく使う書式についてはショートカットボタンも準備されているため、ブラウザ上でサクサクとマークダウン文書を書くことができます。

![emde001a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/ad3d216a-ba1d-67f5-4d05-ec834430dbf9.png)

# 3. EasyMDEでの画像ファイルのアップロード方法

EasyMDEには画像ファイルをアップロードする機能が提供されており、具体的には以下のいずれかのプロパティを用いればWebサーバーにファイルを送信することができます。  

* imageUploadFunction  
画像ファイルの送信と結果の受信処理を行う関数(Function)を指定します。コードを書く量は増えますが、処理の細かな制御が可能です。

* imageUploadEndpoint  
画像ファイルの送信先であるWebサーバのURL(String)を指定します。手軽に画像ファイルのアップロードができますが、細かな制御はできません。Webサーバーからの返却値についても、特定の構造である必要があります。

# 4. EasyMDEから画像ファイルをアップロードする

今回は **imageUploadFunction** プロパティを使って、細かな制御をしてみます。  
なお、サンプルコードではWebサーバーとの非同期通信(Ajax)にjQueryを使用していますが、この辺りについてはaxios、FetchAPI等、お好きなライブラリを使っていただければと思います。

```javascript:JavaScript (EasyMDE生成＆画像データアップロード)
/* EasyMDEの設定は画像アップロードに関わる部分に絞っています。 */
var _imageMaxSize = 1024 * 1024 * 2;  // 画像サイズは2MBまで
var _imageAccept = ['image/jpeg', 'image/png', 'image/gif'];  // JPEG,PNG,GIFを許可

/**
 * EasyMDEの初期化
 * @param elem エディタ対象エレメント(TextArea)
 * @return EasyMDEオブジェクト
 */
function initMDE(elem) {
	var mde = new EasyMDE({
		element: elem,
		uploadImage: true,
		hideIcons: ['image'],
		showIcons: ['upload-image'],
		imageUploadFunction: uploadImage,
		imageMaxSize: _imageMaxSize,
		imageAccept: _imageAccept,
	});
	return mde;
}

/**
 * 画像ファイルのアップロード
 * @param file ファイル
 * @param onSuccess 成功時のコールバック関数
 * @param onError 失敗時のコールバック関数
 */
function uploadImage(file, onSuccess, onError) {
	// ファイルの検証(サイズ、タイプ)
	if (file.size === 0) {
		// ファイル未指定
		onError(constructErrorMessage(400));
		return;
	}
	if (file.size > _imageMaxSize) {
		// サイズ超過
		onError(constructErrorMessage(413));
		return;
	}
	if (file.type) {
		var file_type = file.type.toLowerCase();
		if (!file_type.startsWith('image/')) {
			//ファイル形式が画像でない
			onError(constructErrorMessage(415));
			return;
		}
		if (_imageAccept && _imageAccept.length > 0) {
			if (_imageAccept.indexOf(file_type) === -1 && _imageAccept.indexOf('image/*') === -1) {
				//ファイル形式が許可されていない
				onError(constructErrorMessage(415));
				return;
			}
		}
	} else {
		//ファイル形式が取得できない
		onError(constructErrorMessage(415));
		return;
	}

	var data = new FormData();
	data.append('file', file);

	$.ajax({
		url: '/uploadImageGcs',
		type: 'post',
		enctype: 'multipart/form-data',
		data: data,
		processData: false,
		contentType: false,
	}).done(function(data, textStatus, jqXHR) {
		console.info('data: ' + data);
		onSuccess(data);
	}).fail(function(jqXHR, textStatus, errorThrown) {
		console.error('error: ' + jqXHR.status);
		onError(constructErrorMessage(jqXHR.status));
	});
}

/**
 * エラー時の表示メッセージ生成
 * @param HTTPステータス
 * @return エラーメッセージ
 */
function constructErrorMessage(httpStatus) {
	var fileMsg = 'ファイル名: #image_name#\nファイルサイズ: #image_size#\n送信可能ファイルサイズ: #image_max_size#';
	switch (httpStatus) {
		case 400:	//noFileGiven
			return 'ファイルが指定されていません\n' + fileMsg;
		case 413:	//fileTooLarge
			return 'ファイルが送信可能サイズを超えています\n' + fileMsg;
		case 415:	//typeNotAllowed
			var types = getAllowedFileTypeStr();
			return '許可されないファイル形式です\n' + fileMsg + (types && types.length > 0 ? '\n送信可能ファイル形式: ' + types : '');
		case 500:	//internalServerError
			return 'サーバーで何らかのエラーが発生しました\n' + fileMsg;
		default:
			return '予期せぬエラーが発生しました\n' + fileMsg;
	}
}

/**
 * 許可されているファイル形式を返却
 * @return 許可されているファイル形式(カンマ区切り)
 */
function getAllowedFileTypeStr() {
	var str = '';
	if (_imageAccept && _imageAccept.length > 0) {
		for (var i = 0; i < _imageAccept.length; i++) {
			var sp = _imageAccept[i].split('/');
			str += i > 0 ? ', ' : '';
			str += sp[sp.length - 1];
		}
	}
	return str;
}
```

```html:HTML (ID="mde1"をEasyMDEに変換)
<textarea id="mde1"></textarea>
<script>
    var mde = initMDE($('#mde1').get(0));
</script>
```

### 4.1. JavaScriptの解説
EasyMDEの設定値は以下になります。
※設定は画像アップロードに関するものに限定しています。

|プロパティ|設定値|説明|
|:---|:---|:---|
|uploadImage|true|画像ファイルをアップロードする場合にtrueを指定します(デフォルトはfalse)。これを設定しないと画像ファイルのアップロードが行われません。|
|hideIcons|['image']|ツールバーのイメージアイコンボタンは使わないので非表示にします。<br>ちなみにこのボタンをクリックすると、ファイル選択ダイアログが表示されるわけではなく、<code>\!\[\](https://)</code>が文書内に追加されます。|
|showIcons|['upload-image']|イメージアイコンボタンの代わりに、ツールバーにイメージアップロードアイコンボタンを表示します。<br>このボタンをクリックすると、ファイル選択ダイアログが表示されます。|
|imageUploadFunction|uploadImage (コールバック関数)|アップロード対象の画像ファイルが選択されたときに呼び出される関数(Function)を指定します。<br>関数では、ファイル、成功時のハンドラー(Function)、失敗時のハンドラー(Function)の3つを引数で受け取れるようにします。|
|imageMaxSize|1024\*1024\*2 (2MB)|送信可能な画像データの最大サイズを指定します（単位はバイト）。今回は（EasyMDEによって）この値をエラーメッセージにセットしてもらうために設定しています。<br>ちなみに、imageUploadEndpointプロパティを使って画像ファイルをアップロードする場合は、この値は検証で使用されます。|
|imageAccept|['image/jpeg', 'image/png', 'image/gif']|取り扱いが可能な画像の形式を指定します。<br>ファイル選択ダイアログに表示されるファイルは、ここで指定されたものでフィルターされます。<br>※ファイル選択ダイアログではフィルターの条件を「すべてのファイル」に変更することが可能であるため、この値を設定した場合でも選択ファイルの正当性の検証は必要です。|

各関数の説明は以下になります。

|関数名|引数|戻り値|説明|
|:---|:---|:---|:---|
|initMDE|elem: Element (textarea要素)|EasyMDEオブジェクト|EasyMDEを生成して返します。|
|uploadImage|file: File (ファイル)<br>onSuccess: Function (成功時のハンドラー)<br>onError: Function (失敗時のハンドラー)|なし|ファイルを選択したときに呼び出される関数です。<br>ファイルを送信する前に、ファイルのサイズと形式に問題がないかどうかの検証を行います。問題なければWebサーバーにファイルを非同期で送信します。<br>Webサーバーから結果を受信した時、成功した場合はonSuccess関数の引数に画像ファイルを取得するためのパス(URL)を、失敗した場合はonError関数の引数にエラーメッセージをセットして各々の関数を呼び出します。
|constructErrorMessage|httpstatus: Number (HTTPステータス)|エラーメッセージ|引数のHTTPステータスに対応するエラーメッセージを返します。<br>メッセージ内のプレースホルダ「#image_name#」「#image_size#」「#image_max_size#」はそれぞれ画像のファイル名、ファイルのサイズ、送信可能な最大サイズを表し、これらはEasyMDEによって値がセットされます。|
|getAllowedFileTypeStr|なし|許可されているファイル形式|取り扱い可能なファイル形式を返します。ファイル形式が複数ある場合は、カンマ区切りで結合されます。|

<details><summary>備考: uploadImage関数/非同期通信によるファイル送信</summary><div>
サンプルコードではjQueryのajax関数のパラメータに<br>
<code>processData: false,</code><br>
<code>contentType: false,</code><br>
をセットしていますが、これはjQueryでファイルをアップロードするときの約束事です。<br>
簡単に説明すると、dataプロパティのフォームデータがクエリー文字列に変換されたり、コンテンツタイプヘッダが勝手にセットされたりするのを防止しています。
</div></details>

### 4.2. HTMLの解説
initMDE関数にID="mde1"のテキストエリア要素を渡して、EasyMDEオブジェクトに変換しています。変換後、対象のテキストエリアは画像のアップロードを行えるマークダウンエディタになります。  

# 5. 画像ファイルをWebサーバーで受信する

次はWebサーバー側の処理になります。  
Spring Bootのコントローラーで受信した画像ファイルをGCSに保存します。処理に成功した場合は、その画像データを取得するためのパス(URL)を返却しています。  

```xml:pom.xml (抜粋)
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
				 xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
	<modelVersion>4.0.0</modelVersion>

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.6.2</version>
	</parent>
	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>com.google.cloud</groupId>
				<artifactId>spring-cloud-gcp-dependencies</artifactId>
				<version>3.1.0</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>

	～略～

	<dependencies>
		<!-- SPRING BOOT -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<exclusions>
				<exclusion>
					<groupId>org.springframework.boot</groupId>
					<artifactId>spring-boot-starter-tomcat</artifactId>
				</exclusion>
				<exclusion>
					<groupId>org.hibernate</groupId>
					<artifactId>hibernate-validator</artifactId>
				</exclusion>
				<exclusion>
					<groupId>org.apache.logging.log4j</groupId>
					<artifactId>log4j-to-slf4j</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-logging</artifactId>
		</dependency>

		<!-- SPRING CLOUD GCP -->
		<dependency>
			<groupId>com.google.cloud</groupId>
			<artifactId>spring-cloud-gcp-starter-storage</artifactId>
		</dependency>
	</dependencies>

	～略～

</project>
```

```properties:src/main/resources/application.properties
# ファイルアップロード設定(Spring)
spring.mvc.async.request-timeout=-1
spring.servlet.multipart.max-file-size=2MB
spring.servlet.multipart.max-request-size=10MB
# for GAE
spring.servlet.multipart.location=/tmp

# ファイル保存先(GCS)
image_bucketname=easymde-sample.appspot.com
image_subdirectory=images
```

```java:src/main/java/com/sample\easymde\controllers.UploadImageForGcsController.java
package com.sample.easymde.controllers;

import com.google.cloud.storage.Blob;
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageOptions;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.MvcUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;

/**
 * [easymde-sample] 画像ファイルアップロードコントローラ
 */
@Controller
public class UploadImageForGcsController {
	private static final Logger logger = LoggerFactory.getLogger(UploadImageForGcsController.class);
	// 画像なし(NO IMAGE)の画像へのパス
	private static final String NO_IMAGE_PATH = "/static/images/no_image.png";
	// 画像なし(NO IMAGE)の画像のコンテンツタイプ
	private static final MediaType NO_IMAGE_CONTENT_TYPE = MediaType.IMAGE_PNG;
	// 受け取り可能なコンテンツタイプ
	private static final List<String> IMAGE_ACCEPT_LIST = List.of("image/jpeg", "image/png", "image/gif");

	@Autowired
	private ResourceLoader resourceLoader;

	// バケット名(from application.properties)
	@Value("${image_bucketname}")
	private String bucketName;

	// サブディレクトリ名(from application.properties)
	@Value("${image_subdirectory}")
	private String subDirectory;

	/**
	 * コンストラクタ
	 */
	public UploadImageForGcsController() {}

	/**
	 * 画像ファイルアップロード
	 * @param file マルチパートファイル
	 * @return 画像取得パス
	 */
	@PostMapping(value = "/uploadImageGcs", consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
		produces = "text/plain;charset=utf-8")
	@ResponseBody
	public ResponseEntity<String> uploadFile(@RequestParam("file") MultipartFile file) {
		logger.info("original-filename: " + file.getOriginalFilename());
		String fileContentType = guessContentType(file);
		logger.info("file-contentType: " + fileContentType);

		// ファイルがなければエラー
		if (file == null || file.getSize() == 0) {
			logger.warn("MultipartFile does not exist.");
			return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
		}

		// ファイルタイプの検証(ファイルサイズはSpringでチェック済み)
		if (!IMAGE_ACCEPT_LIST.isEmpty()) {
			if (!IMAGE_ACCEPT_LIST.contains(fileContentType.toLowerCase())) {
				logger.warn("File content type is not allowed. content-type: {}",
					fileContentType);
				return new ResponseEntity<>(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
			}
		}

		String uniqueFilename = getUniqueFilename();
		logger.debug("unique-filename: " + uniqueFilename);

		// ファイルをバイト配列に変換する
		final byte[] byteArray = convertToByteArray(file);
		logger.info("byteArray length: " + (byteArray != null ? byteArray.length : 0));

		// Cloud Storageにファイルを保存する
		Storage storage = StorageOptions.getDefaultInstance().getService();

		// BlobId: bucketName + subDirectory + fileName
		final BlobId blobId = constructBlobId(bucketName, subDirectory, uniqueFilename);
		BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType(fileContentType).build();
		Blob blob = storage.create(blobInfo, byteArray);
		logger.debug("blob: " + blob == null ? null : blob.toString());
		if (blob == null) {
			logger.error("Failed to save the file to cloud storage.\nblobId: {}",
				blobId.toString());
			return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
		}

		HttpHeaders headers = new HttpHeaders();
		String path = getDownloadPath(uniqueFilename);
		headers.setContentLength(path.getBytes(StandardCharsets.UTF_8).length);
		logger.info("download-path: " + path);

		return new ResponseEntity<>(path, headers, HttpStatus.OK);
	}

	/**
	 * 画像ファイルファイルダウンロード
	 * @param fileId ファイルID
	 * @return 画像データ
	 */
	@GetMapping(value = "/downloadImageGcs/{fileId}")
	@ResponseBody
	public HttpEntity<byte[]> downloadFile(@PathVariable String fileId) {
		logger.debug("file-id: " + fileId);

		// Cloud Storageからファイルを取得する
		Storage storage = StorageOptions.getDefaultInstance().getService();

		// BlobId: bucketName + subDirectory + fileName
		final BlobId blobId = constructBlobId(bucketName, subDirectory, fileId);
		final Blob blob = storage.get(blobId);
		final HttpHeaders headers = new HttpHeaders();
		byte[] byteArray = null;

		if (blob != null && blob.getSize() > 0) {
			//Blobオブジェクトの内容を取得
			byteArray = blob.getContent(Blob.BlobSourceOption.generationMatch());
			MediaType contentType = MediaType.parseMediaType(blob.getContentType());
			headers.setContentType(contentType);
		} else {
			//NO IMAGEを返す
			byteArray = getNoImage();
			headers.setContentType(NO_IMAGE_CONTENT_TYPE);
		}
		headers.setContentLength(byteArray == null ? 0 : byteArray.length);

		return new HttpEntity<>(byteArray, headers);
	}

	/**
	 * ファイルの内容をバイト配列に変換して返します
	 * @param file ファイル
	 * @return ファイルの内容のバイト配列
	 */
	private byte[] convertToByteArray(MultipartFile file) {
		try {
			return file.getBytes();
		} catch (IOException e) {
			logger.error("read request body error...", e);
			return null;
		}
	}

	/**
	 * ユニークファイル名を返します
	 * @return ファイル名
	 */
	private String getUniqueFilename() {
		return UUID.randomUUID().toString();
	}

	/**
	 * ファイルのコンテンツタイプを推測して返します
	 * @param file マルチパートファイル
	 * @return コンテンツタイプ(不明な場合は"application/octet-stream")
	 */
	private String guessContentType(MultipartFile file) {
		final StringBuilder sb = new StringBuilder();
		try (InputStream is = file.getInputStream()) {
			String result = URLConnection.guessContentTypeFromStream(is);
			if (StringUtils.isNotEmpty(result)) {
				sb.append(result);
			}
		} catch (IOException e) {
			logger.warn("ContentType not found: " + file.getOriginalFilename(), e);
		}

		if (sb.length() == 0) {
			String result = URLConnection.guessContentTypeFromName(file.getOriginalFilename());
			if (StringUtils.isNotEmpty(result)) {
				sb.append(result);
			}
		}

		return sb.length() == 0 ? "application/octet-stream" : sb.toString();
	}

	/**
	 * ダウンロード用のパスを返します
	 * @param fileId ファイルID
	 * @return ダウンロード用のパス
	 */
	private String getDownloadPath(String fileId) {
		UriComponentsBuilder builder = MvcUriComponentsBuilder
			.fromMethodName(UploadImageForGcsController.class, "downloadFile", fileId);
		return builder.build().getPath();
	}

	/**
	 * 画像なし(No Image)の画像を返します
	 * @return 画像のバイト配列
	 */
	private byte[] getNoImage() {
		Resource resource = resourceLoader.getResource("classpath:" + NO_IMAGE_PATH);
		try (InputStream image = resource.getInputStream()) {
			return IOUtils.toByteArray(image);
		} catch (IOException e) {
			logger.warn("no image file open failure...", e);
		}

		return null;
	}

	/**
	 * BlobIDを生成して返します
	 * @param bucketName バケット名
	 * @param subdirectory サブディレクトリ名
	 * @param fileName ファイル名
	 * @return BlobId
	 */
	private BlobId constructBlobId(String bucketName, String subdirectory, String fileName) {
		return BlobId.of(bucketName,
			subdirectory +
			(subdirectory.endsWith("/") ? "" : "/") +
			fileName);
	}
}
```

＜備考＞
ローカルデバッグ等、GCSへのアクセスが許可されていない環境で実行する場合は、**環境変数(spring.cloud.gcp.credentials.location)** に資格情報ファイルへのパスを設定する必要があります。
* 環境変数の設定例
  ```spring.cloud.gcp.credentials.location=file:/usr/local/key.json```
引用元: [Spring Cloud GCP](https://googlecloudplatform.github.io/spring-cloud-gcp/reference/html/index.html#credentials)

### 5.1. Maven設定ファイル(pom.xml)の解説
プロジェクトが依存するライブラリの定義には、Spring Bootに加え、SpringでGCSを扱うためのライブラリ(以下)を定義します。
```
		<!-- SPRING CLOUD GCP -->
		<dependency>
			<groupId>com.google.cloud</groupId>
			<artifactId>spring-cloud-gcp-starter-storage</artifactId>
		</dependency>
```

### 5.2. Spring設定ファイル(application.properties)の解説
アップロードに関係する設定のみを抜粋しました。
内容は以下になります。

|プロパティ|設定値|説明|
|:---|:---|:---|
|spring.mvc.async.request-timeout|-1|非同期リクエスト処理がタイムアウトするまでの時間を指定します。ここでは無制限(-1)を指定しています。|
|spring.servlet.multipart.max-file-size|2MB|最大ファイルサイズを2MBとしています。|
|spring.servlet.multipart.max-request-size|10MB|最大リクエストサイズを10MBとしています。|
|spring.servlet.multipart.location|/tmp|アップロードされたファイルが一時保存される場所を/tmp としています。ファイルがオンメモリで処理可能なサイズを超えている場合、この場所に出力されます。<br>※GAEではローカルリソースへのアクセスが制限されていますが、/tmpディレクトリについては唯一アクセスが許可されています（アクセス制限がされていない環境では定義不要です）。|
|image_bucketname|easymde-sample.appspot.com|ファイル保存先になるGCSのバケット名を定義しています。|
|image_subdirectory|images|ファイルが保存されるサブディレクトリを定義しています。<br>ファイルは「バケット名/サブディレクトリ」の下に保存されることになります。|

### 5.3. コントローラ(UploadImageForGcsController.java)の解説
画像ファイルのアップロード、ダウンロードの処理を行うコントローラクラスです。
それらを大雑把に説明すると以下のようになります。

アップロード処理(uploadFileメソッド)では、ファイルの検証を行い、問題なければ対象のファイルを**ユニークな名前**でGCSに保存します。これは中身の異なる同じ名前のファイルが複数アップロードされた場合に、意図せずファイルが上書きされるのを防ぐためです。ちなみに、ファイル名はUUIDで採番しています。
一連の処理が終わったら、ファイルのダウンロードパスを呼び出し元に返却しています。

<details><summary>備考: ファイルのダウンロードパスの生成方法</summary><div>
MvcUriComponentsBuilder.fromMethodName(対象のクラス, メソッド名, 引数...) メソッドを使用すると、「クラス+メソッド名」に紐づくパスを生成することができます。<br>
サンプルコードでは、downloadFileメソッドに<br>
<code>@GetMapping(value = "/downloadImageGcs/{fileId}")</code><br>
アノテーションが設定されており、これを基にパスが生成されています。
</div></details>

ダウンロード処理(downloadFileメソッド)では、パスのファイルID部分(ユニークファイル名)に該当する画像ファイルをGCSから取得し、返却します。該当するファイルが見つからなかった場合は、画像なし用の画像「no_image.png」を返却するようにしています。

各メソッドの説明は以下になります。
|メソッド名|引数|戻り値|説明|
|:---|:---|:---|:---|
|(public)<br>uploadFile|file: MultipartFile (アップロードファイル)|ResponseEntity\<String\> (ダウンロードパス)|アップロードされたファイルをGCSに保存します。保存時にはファイルのコンテンツタイプも登録します。|
|(public)<br>downloadFile|fileId: String (ファイルID)|HttpEntity\<byte[]\> (ファイルデータ[バイト配列])|ファイルIDに該当するファイルデータを返します。|
|(private)<br>convertToByteArray|file: MultipartFile (ファイル)|byte[] (ファイルの内容[バイト配列])|ファイルの内容をバイト配列に変換して返します。|
|(private)<br>getUniqueFilename|なし|String (ユニークファイル名)|UUIDでユニークなファイル名を採番して返します。|
|(private)<br>guessContentType|file: MultipartFile (ファイル)|String (コンテンツタイプ)|ファイルのコンテンツタイプを返します。コンテンツタイプはファイルの内容から推測していますが、それで判断できない場合はファイル名の拡張子から求めています。|
|(private)<br>getDownloadPath|fileId: String (ファイルID)|String (ダウンロードパス)|ファイルをダウンロードするためのパスを返します。|
|(private)<br>getNoImage|なし|byte[] (ファイルの内容[バイト配列])|画像なし(NO IMAGE)の画像ファイルの内容をバイト配列に変換して返します。|
|(private)<br>constructBlobId|bucketName: String (バケット名)<br>subdirectory: String (サブディレクトリ名)<br>fileName: String (ファイル名)|BlobId (ブロブID)|バケット名、サブディレクトリ名、ファイル名よりブロブIDを生成して返します。|


# 6. 完成！

EasyMDEで早速画像をアップロードしてみましょう。
以下のいずれの方法でもアップロードできます。

* イメージアップロードボタンでのファイル選択
* 画像ファイルのドラッグ＆ドロップ
* クリップボードからの画像データ貼り付け

![emde004a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/1694e77d-59df-1550-66a1-396b354c953c.png)


# 7. おわりに

EasyMDEでの画像ファイルのアップロード、いかがでしたでしょうか。
想像していたよりも簡単に実現できそうだ、と思われた方もいらっしゃるのではないかと思います。

なお、サンプルコードでは、アップロードに関係のないコードを極力省いていますので、機能追加・改善の余地は大いにあります。
たとえばアップロード処理では完了までに時間がかかるかもしれないので、アップロード中はローディング画像を表示するとよいでしょう。
また、Webサーバー処理でのGCSへのファイル保存先についても、目的別(たとえばユーザー単位や日単位、等)にディレクトリを分けると、管理しやすくなると思います。


# 8. 参考にさせていただいた情報

GitHub - Ionaru/easy-markdown-editor: EasyMDE
https://github.com/Ionaru/easy-markdown-editor

Spring Cloud GCP
https://googlecloudplatform.github.io/spring-cloud-gcp/reference/html/index.html

[Résolu] Implanter Easy Markdown Editor &bull; Forum &bull; Zeste de Savoir
https://zestedesavoir.com/forums/sujet/12929/implanter-easy-markdown-editor

ajax(jQuery)でファイルをアップロードする - Qiita
https://qiita.com/tkek321/items/84f6a82bbca66cc89848