📁

Spring Boot + ThymeleafでCSVファイルをダウンロードする

2024/04/19に公開

はじめに

様々なアプリケーションに組み込まれているファイルダウンロードの仕組み。
みなさんは実装したことありますか?
今回はCSVファイルダウンロードの実装をしてみました。

使用技術

Jackson

JacksonはJavaでJSON形式のデータを扱うためのライブラリです。
CSV,Properties,TOML,YAMLなどのデータを扱うためのモジュールも存在していて、今回はCSVを使用するので、jackson-dataformat-csvを使用します。

要件

Thymeleafで作ったリンクを押下するとCSVファイルがダウンロードできる。

実装

画面表示

まずはControllerと画面を作って表示させていくー。

FileDownLoadController
package com.example.demo.controller

import org.springframework.stereotype.Controller
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping

@Controller
@RequestMapping("/download")
class FileDownLoadController() {
    @GetMapping
    fun index(): String {
        return "/index"
    }
}
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>ファイルダウンロード</title>
  </head>
  <body>
    <a th:href="@{/download/csv}">CSVダウンロード</a>
  </body>
</html>

こんな画面が表示される。

ファイルダウンロード

画面ができたので、ここからCSVダウンロード出来るようにしていきます。
今回は生徒のデータをCSVに出力します。

StudentDto
package com.example.demo.dto

data class StudentDto(
    val id: String,
    val name: String,
    val department: String,
)

ControllerのdownloadCsvにダウンロード処理を書いていきます。

FileDownLoadController
package com.example.demo.controller

import com.example.demo.dto.StudentDto
import com.fasterxml.jackson.dataformat.csv.CsvMapper
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
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.RequestMapping

@Controller
@RequestMapping("/download")
class FileDownLoadController() {
    companion object {
        private val MEDIA_TYPE_TEXT_CSV = MediaType("text", "csv")
        private const val CONTENT_DISPOSITION_HEADER = "attachment; filename=test.csv; filename*=UTF-8"
    }

    @GetMapping
    fun index(): String {
        return "/index"
    }

    @GetMapping("/csv")
    fun downloadCsv(): ResponseEntity<ByteArray> {
        // 生徒の仮データ
        val studentList =
            listOf(
                StudentDto("a20019", "テスト1", "TB"),
                StudentDto("b20008", "テスト2", "UT"),
                StudentDto("c20198", "テスト3", "CT")
            )
        val csvMapper = CsvMapper()
        val schema = csvMapper.schemaFor(StudentDto::class.java).withHeader()
        val studentText = csvMapper.writer(schema).writeValueAsString(studentList)
        val csvBytes = studentText.toByteArray(Charsets.UTF_8)

        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, CONTENT_DISPOSITION_HEADER)
            .contentType(MEDIA_TYPE_TEXT_CSV)
            .body(csvBytes)
    }
}

ざっくり解説

まず、CsvMapperで良い感じにゴニョゴニョしつつ、生徒のデータをバイト配列に変換しています。

// 生徒の仮データ
val studentList =
    listOf(
        StudentDto("a20019", "テスト1", "TB"),
        StudentDto("b20008", "テスト2", "UT"),
        StudentDto("c20198", "テスト3", "CT")
    )
val csvMapper = CsvMapper()
val schema = csvMapper.schemaFor(StudentDto::class.java).withHeader()
val studentText = csvMapper.writer(schema).writeValueAsString(studentList)
val csvBytes = studentText.toByteArray(Charsets.UTF_8)

その後、HttpヘッダーのContent-Dispositionattachment; filename=test.csv; filename*=UTF-8を指定してレスポンスを返しています。

return ResponseEntity.ok()
    .header(HttpHeaders.CONTENT_DISPOSITION, CONTENT_DISPOSITION_HEADER)
    .contentType(MEDIA_TYPE_TEXT_CSV)
    .body(csvBytes)

このContent-Dispositionが重要で、これはなんぞやというと、コンテンツをウェブページとして表示するか(inline)、ダウンロードするか(attachment)を決めるもので、"attachment; filename=test.csv; filename*=UTF-8"のような指定をしてあげるとそのファイル名・文字コードのファイルをダウンロードできます。

動かしてみる

ダウンロードリンクを押下すると...ダウンロードできたー🥳

中身も問題なし!

おわりに

ここまで読んでいただきありがとうございました!
良ければ番外編も見ていってください!

番外編

お気づきの方もいるかと思いますが、この実装には問題点があるんです...
それは何かというと...
Excelで開くと文字化けします...

なぜ文字化けするのか

BOMがついていないからです!

なるほど! or そうだよねー。と思った方はBOMをつけてあげてください。
わからん... or BOMってなんやねん状態の人は以降で解説していきます!

そもそもBOMとは?

BOM(Byte Order Mark)は、テキストファイルや文字コードがUTF-8やUTF-16などのUnicode形式で保存されている場合に、ファイルの先頭に追加される特殊なバイト列です。

ExcelなどはBOMを見て、ファイルの符号化形式を判別するので、BOMがついてないと正しく判別できずに文字化けしてしまいます。

実装修正

以下のように、生徒データの前に文字コード(今回はUTF-8)のバイト列をつけてあげます。(val csvBytes = UTF8_BOM + studentText.toByteArray(Charsets.UTF_8)の部分)

FileDownLoadController
package com.example.demo.controller

import com.example.demo.dto.StudentDto
import com.fasterxml.jackson.dataformat.csv.CsvMapper
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.HttpHeaders
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.RequestMapping

@Controller
@RequestMapping("/download")
class FileDownLoadController() {
    companion object {
        private val MEDIA_TYPE_TEXT_CSV = MediaType("text", "csv")
        private val UTF8_BOM = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
        private const val CONTENT_DISPOSITION_HEADER = "attachment; filename=test.csv; filename*=UTF-8"
    }

    @GetMapping
    fun index(): String {
        return "/index"
    }

    @GetMapping("/csv")
    fun downloadCsv(response: HttpServletResponse): ResponseEntity<ByteArray> {
        // 生徒の仮データ
        val studentList =
            listOf(
                StudentDto("a20019", "テスト1", "TB"),
                StudentDto("b20008", "テスト2", "UT"),
                StudentDto("c20198", "テスト3", "CT")
            )
        val csvMapper = CsvMapper()
        val schema = csvMapper.schemaFor(StudentDto::class.java).withHeader()
        val studentText = csvMapper.writer(schema).writeValueAsString(studentList)
        val csvBytes = UTF8_BOM + studentText.toByteArray(Charsets.UTF_8)

        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION, CONTENT_DISPOSITION_HEADER)
            .contentType(MEDIA_TYPE_TEXT_CSV)
            .body(csvBytes)
    }
}

すると.............文字化けしてない!!!!!

おわり2

ここまで読んでいただいた方、ほんっっっっっとうにありがとうございました!
何か間違ってる部分があれば優しく教えていただけると嬉しいです。

Discussion