vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Kotlin】【Windows】JavaFX + Apache POI + GsonでExcelからJsonファイルを作る

はじめに

このところずっと取り組んでいる和歌山トイレマップを使ったアプリですが、トイレ情報はCSV形式で保存したファイルから取得しています。
これは、元データである和歌山トイレマップCSV形式であり、アプリを作り始めた当初はデータをそのまま使用する予定でいたためです。

ただ現在は別途取得した緯度・経度を加えたり、あれこれデータをいじっているため特にCSVである必要はなくなってきました。
加えてCSVに比べ、Jsonの方が解析などのためのツールが揃っている印象もあります。

で、Spreadsheet(Excel)のデータをJson形式で出力するツールを探してみたのですが、いまいちしっくりこず…。

というわけで、自作してみることにしました。

Kotlinを使って。

https://github.com/masanori840816/KtCreateJson

GUIを作る

今回は(今回も)チャラくいきたかったので、GUIで読み込むファイルを選択してシートを指定→ファイルを読み込んでJsonファイルを出力することにしました。

ということでGUIなのですが、JavaFXを使ってみることにしました。
Kotlin用のGUIフレームワークも探せばありそうな気はしたのですが、それほど豊富に情報がなさそうだったので(ヘタレ)。

あと、以前ある方がJavaFXを使ってプレゼンをしていたのを見て以来気になってはいた、ということもあります。

FXMLを使ってみる(失敗)

Gluon Scene Builderといったツールを使うことで、ドラッグ&ドロップGUIパーツを配置できます。

出来上がったファイル(FXML)を読み込むためには以下のようにします。

MainApplication.fxml

< ?xml version="1.0" encoding="UTF-8"? >

< ?import javafx.scene.control.Button? >
< ?import javafx.scene.control.ComboBox? >
< ?import javafx.scene.control.TextField? >
< ?import javafx.scene.layout.StackPane? >

< StackPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/8.0.60" xmlns:fx="http://javafx.com/fxml/1" >
    < Button fx:id="findFileButton" onAction="#onFindFileButtonClicked" mnemonicParsing="false" text="参照" translateX="250.0" translateY="-150.0" / >
    < Button fx:id="createButton" onAction="#onCreateButtonClicked" mnemonicParsing="false" scaleX="1.3" scaleY="1.3" text="作成" translateX="230.0" translateY="150.0" / >
    < ComboBox fx:id="sheetNameCombobox" prefWidth="150.0" translateX="-200.0" translateY="-70.0" / >
    < TextField fx:id="loadFilePathField" scaleX="0.8" translateX="-35.0" translateY="-150.0" / >
< /StackPane >

MainApplication.kt

import javafx.application.Application
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.fxml.FXML
import javafx.fxml.FXMLLoader
import javafx.scene.Parent
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.control.ComboBox
import javafx.scene.control.TextField
import javafx.stage.Stage
import java.io.IOException

class MainApplication : Application(){
    // 変数名をキーにFXMLのGUIと関連付ける.
    @FXML
    lateinit private var loadFilePathField: TextField
    @FXML
    lateinit private var sheetNameCombobox: ComboBox< String >

    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {
        // FXMLの読み込み
        val root = FXMLLoader.load< Parent >(javaClass.getResource("MainApplication.fxml"))
        val primaryScene = Scene(root)
        primaryStage.setScene(primaryScene)
        // ウインドウを表示する.
        primaryStage.show()
    }
    fun onFindFileButtonClicked(){
        // findFileButtonがクリックされた時に呼ばれる.
    }
    fun onCreateButtonClicked(){
        // createButtonがクリックされた時に呼ばれる.
    }
}
  • 「@FXML」を付けることで、FXML側の「fx:id」と、コード側の変数名とを関連付けます。そのため、これら2つの名前が異なっているとうまく関連付けされず、Nullになってしまいます。
  • 「@FXML」の関連付けが行われるのはstartの後のようで、それより前にGUIにアクセスしようとするとぬるぽになります。
  • クリックなどのイベントは、FXMLで「onAction」で指定することも、コードで「setOnAction」とすることもできます。

問題

ここまでは特に問題なく表示ができるのですが、Comboboxに値をセットしようとした辺りでハマってしまいました。

下記のようにstartで値を入れようとすると、何故か関連付けたGUIまでNullになってしまう、という問題が発生します。

MainApplication.kt

~省略~
    @FXML
    lateinit private var sheetNameCombobox: ComboBox< String >

    private var sheetNameList: ObservableList< String >? = null
    
    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {
        // FXMLの読み込み
        val root = FXMLLoader.load< Parent >(javaClass.getResource("MainApplication.fxml"))
        val primaryScene = Scene(root)
        primaryStage.setScene(primaryScene)
        // ウインドウを表示する.
        primaryStage.show()
        
        sheetNameList = FXCollections.observableArrayList()
        sheetNameList.add("test")
        sheetNameCombobox.items = sheetNameList
    }
~省略~

結局解決方法がよくわからず、FXMLではなくコードでGUIを追加することにしました。

コード上でGUIを追加する

コード上でGUIを追加するため、以下のように記述しました。

MainApplication.kt

import javafx.application.Application
import javafx.collections.FXCollections
import javafx.collections.ObservableList
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.control.ComboBox
import javafx.scene.control.TextField
import javafx.scene.layout.StackPane
import javafx.stage.FileChooser
import javafx.stage.Stage
import java.io.File
import java.io.IOException

class MainForm : Application(){
~省略~

    lateinit private var loadFilePathField: TextField
    lateinit private var sheetNameCombobox: ComboBox< String >
    lateinit private var createButton: Button

    private var sheetNameList: ObservableList< String >? = null
    private var selectedFile: File? = null

    @Throws(IOException::class)
    override fun start(primaryStage: Stage) {

        spreadsheetAccesser = SpreadsheetAccesser()
        jsonFileCreator = JsonFileCreater()

        // ファイル選択用ダイアログを表示するボタン.
        val findFileButton = Button()
        findFileButton.text = "参照"
        findFileButton.translateX = 250.0
        findFileButton.translateY = -150.0
        findFileButton.setOnAction { event -> run {
                // ボタンクリック時の処理.
            }
        }
        // 読み込むSpreadsheetのパスを入力、表示するテキストフィールド.
        loadFilePathField = TextField()
        loadFilePathField.translateX = -35.0
        loadFilePathField.translateY = -150.0
        loadFilePathField.scaleX = 0.8
        loadFilePathField.setOnAction { event -> run{
            // テキストフィールドでEnterを押された時の処理.
        } }

        sheetNameList = sheetNameList?: FXCollections.observableArrayList("")

        // 読み込んだSpreadsheetのシート名を選択するコンボボックス.
        sheetNameCombobox = ComboBox()
        sheetNameCombobox.translateX = -200.0
        sheetNameCombobox.translateY = -70.0
        sheetNameCombobox.prefWidth = 160.0
        if(sheetNameList != null){
            sheetNameCombobox.items = sheetNameList
        }
        
        // Jsonのファイルを作成するボタン.
        createButton = Button()
        createButton.text = "作成"
        createButton.translateX = 230.0
        createButton.translateY = 150.0
        createButton.setOnAction { event -> run{
            // ボタンクリック時の処理.
        } }

        // StackPaneを作成してGUIを追加する.
        val stackPane = StackPane()
        stackPane.children.addAll(findFileButton
                , loadFilePathField
                , sheetNameCombobox
                , createButton)

        val primaryScene = Scene(stackPane, 600.0, 400.0)
        primaryStage.setScene(primaryScene)
        primaryStage.show()
    }
  • 今回各GUIはStackPaneに追加しています。このときaddAllを使うことで、一括で追加ができます。

FileChooser

読み込むSpreadsheetを選択するためのダイアログを表示します。

JavaFXではFileChooserを使用します。

MainApplication.kt

~省略~
    findFileButton.setOnAction { event -> run {
            // FileChooserでダイアログを表示する.
            val fileChooser = FileChooser()
            fileChooser.title = "ファイルを選択"
            // 選択対象のファイルの拡張子を指定する.
            fileChooser.extensionFilters.add(FileChooser.ExtensionFilter("Spreadsheet", "*.xlsx", "*.ods"))
            // ダイアログの表示.
            selectedFile = fileChooser.showOpenDialog(primaryStage)
            // 選択したファイルのパスをテキストフィールドに入力する.
            loadFilePathField.text = selectedFile.toString()
            // 選択したファイルからシート名を取得して、コンボボックスにセットする(※後述).
            setSheetNames()
        }
    }
~省略~

ApachePOIでシート名を取得する

Apache POIを使ってSpreadsheetからシート名を取得します。

インストール

まずApache POIを使うために、プロジェクト直下にあるbuild.gradleに以下を追加します。

build.gradle

~省略~
dependencies {
 ~省略~
    compile group: 'org.apache.poi', name : 'poi', version: '3.14'
    compile(group: 'org.apache.poi', name : 'poi-ooxml', version: '3.14') {
        exclude group: 'stax', module: 'stax-api'
    }
~省略~
}

シート名の取得

SpreadsheetAccesser.kt

import javafx.collections.FXCollections
import javafx.collections.ObservableList
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.WorkbookFactory
import java.io.FileInputStream
import java.util.ArrayList

class SpreadsheetAccesser {
~省略~
    fun getSheetNames(targetFilePath: String): ObservableList< String >?{
        // SpreadsheetのパスからStreamを作成する.
        val fileStream = FileInputStream(targetFilePath)
        // StreamからWorkbookを取得.
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return null
        }
        // ComboboxにセットするためのArrayListの生成.
        val sheetNameList: ObservableList = FXCollections.observableArrayList()

        // 取得したWorkbookからシート名を取得する.
        for (i in 0..currentWorkbook.numberOfSheets - 1){
            sheetNameList.add(currentWorkbook.getSheetName(i))
        }
        // Workbook、Streamを閉じる.
        currentWorkbook.close()
        fileStream.close()
        return sheetNameList
    }
~省略~
}
  • 本当は非同期で実行した方が良さそうですが、今回は特に何もせずそのまま実行しています。

ApachePOIでセルの値を取得する

読み込む対象のSpreadsheet、シート名が決まったら、セルの値を取得します。

今回は1列目の各セルの値を取得して、それが何列分あるかによって実際の値を読み込む時の列数を指定します。

SpreadsheetAccesser.kt

~省略~
    fun loadFile(targetFilePath: String, targetSheetName: String){
        // SpreadsheetのパスからStreamを作成する.
        val fileStream = FileInputStream(targetFilePath)
        // StreamからWorkbookを取得.
        val currentWorkbook = WorkbookFactory.create(fileStream)

        if(currentWorkbook == null){
            return
        }
        // シート名から対象のシートを取得する.
        val targetSheet: Sheet? = currentWorkbook.getSheet(targetSheetName)
        if(targetSheet == null){
            return
        }
        // セルに何らかの値が含まれる行数の取得.
        val rowCount = targetSheet.physicalNumberOfRows - 1
        if(rowCount < 0){
            return
        }
        // 最初の行から列数を取得する.
        val columnCount = targetSheet.getRow(0).physicalNumberOfCells - 1

        // 最初の行をタイトル行としてArrayListを作成する.
        ColumnTitleList = ArrayList()
        for(cell in targetSheet.getRow(0)){
            ColumnTitleList.add(getCellValue(cell))
        }
        // 実際の値が入ったセルの値をセットするArrayListの生成.
        LoadedSheetItemList = ArrayList< ArrayList < String > >()

        // タイトル行から取得した列数 ✕ (セルに値が含まれる行数 - 1)の値をセットする.
        for(i in 1..rowCount){
            val loadedRowItemList = ArrayList< String >()
            loadedRowItemList.add(i.toString())

            for(t in 1..columnCount){
                loadedRowItemList.add(getCellValue(targetSheet.getRow(i).getCell(t)))
            }
            LoadedSheetItemList.add(loadedRowItemList)
        }
        // Workbook、Streamを閉じる.
        currentWorkbook.close()
        fileStream.close()
    }
    fun getCellValue(targetCell: Cell?): String{
        var result = ""
        if(targetCell == null){
            return result
        }
        // SpreadsheetにおけるCellの型によらず、一律でStringとしてCellの値を取得.
        when(targetCell.cellType){
            Cell.CELL_TYPE_BOOLEAN -> return targetCell.booleanCellValue.toString()
            Cell.CELL_TYPE_NUMERIC -> return targetCell.numericCellValue.toString()
            Cell.CELL_TYPE_STRING -> return targetCell.stringCellValue.toString()
            Cell.CELL_TYPE_FORMULA -> return targetCell.cellFormula.toString()
        }
        return result
    }
}
  • Spreadsheetから値を取る際に、Cell.XXXXCellValueのデータ型とセルのデータ型とが合致しないと(例:targetCell.booleanCellValueで文字列型のセルから値を取得しようとすると)、エラーが発生します。
    そのためwhenを使ってセルのデータ型に合わせて値を取得するようにしています(getCellValue内)。

Jsonファイルを作成する

Spreadsheetから値が取得できたら、それをJsonとしてファイルに書き出します。

今回はGsonを使うことにしました。

インストール

プロジェクト直下にあるbuild.gradleに以下を追加します。

build.gradle

~省略~
dependencies {
~省略~
    compile "com.google.code.gson:gson:2.7"
}

Jsonファイルに書き出す

※2016.07.22 20:11更新
Jsonオブジェクトを作るところで誤りがあり、全データが一つの塊になっていたため、バラバラに分割するよう修正しました。

JsonFileCreater.kt

import com.google.gson.stream.JsonWriter
import java.io.BufferedWriter
import java.io.FileWriter
import java.io.IOException
import java.io.StringWriter
import java.util.ArrayList

class JsonFileCreater {
    fun createFile(titleList: ArrayList< String >, valueListCollection: ArrayList< ArrayList< String > >, fileTitle: String){
        val stringWriter = StringWriter()
        
        val jsonWriter = JsonWriter(BufferedWriter(stringWriter))
        // Jsonオブジェクトの作成開始.
    jsonWriter.beginObject()

    var i = 0
        valueListCollection.forEach { valueList -> run{
                jsonWriter.name("toilet_list_" + i.toString())
                jsonWriter.beginObject()
                for(t in 0..valueList.size - 1){
                    // Jsonの項目名(name)と値(value)をそれぞれ指定する.
                    jsonWriter.name(titleList[t]).value(valueList[t])
                }
                jsonWriter.endObject()
                i++
            }
        }

        // Jsonオブジェクトの作成を終了して文字列にする.
        jsonWriter.endObject()
        jsonWriter.close()
        val createdJson = String(stringWriter.buffer)

        try{
            // 読み込んだSpreadsheetのファイル名を使ってJsonファイルを書き出す.
            val splittedTitles = fileTitle.split('.')
            if(splittedTitles.size <= 0){
                return
            }
            val fileWriter = FileWriter(splittedTitles[0] + ".json")
            fileWriter.write(createdJson)
            fileWriter.close()
        }catch(e: IOException){
            // TODO: 適切なエラー処理
        }
    }
}

おわりに

とりあえずそれっぽいファイルを作成することはできました。

ただ、セルから取り出した値を全て文字列型にしてしまっているため、例えば緯度・経度の値も文字列として扱われてしまいます。
今回のように使いみちが決まっている場合は、値を格納するためのクラスを専用に作成して、それぞれのデータ型のままJsonに書き出す方が良いかもしれません。

またはフラグを立てるとか...?

あと途中でも書きましたが、今回別スレッドでの実行や非同期処理を行っていないため、本当はその部分についても考慮が必要になるかと思います。

あと今回はKotlinで挑戦してみましたが、Androidアプリでは無いのだし、どうせなのでJavaで書いてver.8系の仕様を堪能する、ということにしても良かったかもしれません。

次は今回作成したJsonファイルを、Androidアプリから読み込むお話になるはず。

参考

JavaFX

Apache POI

File Chooser

IntelliJ IDEA

Gson