ほんじゃらねっと

ダイエット中プログラマのブログ

昨日食べたものも思い出せなくなってきたおっさん(自分)のために食事履歴記録アプリをつくった

f:id:piro_suke:20160320014313j:plain

おじさんになると、

よっぽど興味を持ったこと以外はすぐに忘れてしまうようだ。

忘れるというよりも、覚えてるけどうまく思い出せない、というべきか。

今週ランチで食べたものを思い出してみようとしても、

昨日のメニューすらなかなか出てこないことがある。

頭で覚えられないならしかたない、頭の外でデータ化しよう、

ということで今回は食べたものを登録できるツールを作ってみる。

まずはシンプルな機能で

まずは食事内容を入力したらデータベースに登録してくれる

シンプルな機能として実装してみよう。

登録内容は

  • 年月日
  • 食事区分 (朝食/昼食/夕食)
  • 主食
  • 主菜(メインディッシュ)
  • 副菜(サブディッシュ。複数)

くらいで始める。

いつもはコマンドを流すだけのスクリプトを作ることが多いけど、

今回は入力しやすいようにGUIアプリに挑戦する。

こんな入力画面を作るイメージ:

f:id:piro_suke:20160806012411p:plain

Clojure + JavaFX + PostgreSQLで実装する

スマホアプリではなく、デスクトップアプリ。

環境は、

  • Java 8
  • Clojure 1.8
  • PostgreSQL 9.5

Clojureベースで作りたいので、JavaのGUIライブラリである

JavaFXと組み合わせる。

JavaFXはJava8から追加されたSwingに代わる標準GUIライブラリらしく、

初めて使ったけど、思ったより作りやすかった。

初心者のためのJavaFXプログラミング入門

JavaDoc API

JavaFX 8

HTML風のFXMLと呼ばれるレイアウト定義ファイルと独自のCSSを

組み合わせてレイアウトを外部化できる。

Scene Builderという無料ツールも提供されており、これを使うと

GUIでレイアウトを組み立てていくことができる。楽。

JavaFX Scene Builder Information

Scene BuilderでFXMLファイルを作成する

Scene Builderで上の入力画面イメージを作って保存したFXMLファイルがこちら。

app.fxml

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

<?import javafx.geometry.*?>
<?import javafx.scene.control.*?>
<?import java.lang.*?>
<?import javafx.scene.layout.*?>


<VBox xmlns="http://javafx.com/javafx/8" xmlns:fx="http://javafx.com/fxml/1">
   <children>
      <GridPane prefHeight="253.0" prefWidth="574.0">
        <columnConstraints>
          <ColumnConstraints hgrow="SOMETIMES" maxWidth="282.0" minWidth="10.0" prefWidth="187.0" />
          <ColumnConstraints hgrow="SOMETIMES" maxWidth="391.0" minWidth="10.0" prefWidth="391.0" />
        </columnConstraints>
        <rowConstraints>
          <RowConstraints maxHeight="45.0" minHeight="10.0" vgrow="SOMETIMES" />
          <RowConstraints maxHeight="84.0" minHeight="10.0" vgrow="SOMETIMES" />
          <RowConstraints maxHeight="109.0" minHeight="10.0" vgrow="SOMETIMES" />
            <RowConstraints minHeight="10.0" vgrow="SOMETIMES" />
            <RowConstraints maxHeight="154.0" minHeight="10.0" prefHeight="74.0" vgrow="SOMETIMES" />
            <RowConstraints maxHeight="104.0" minHeight="0.0" vgrow="SOMETIMES" />
        </rowConstraints>
         <children>
            <Label text="日付" />
            <Label text="食事区分" GridPane.rowIndex="1" />
            <Label text="主食" GridPane.rowIndex="2" />
            <Label text="メインディッシュ" GridPane.rowIndex="3" />
            <Label text="サイドディッシュ" GridPane.rowIndex="4" />
            <DatePicker fx:id="daySelect" GridPane.columnIndex="1" />
            <TextField fx:id="stapleTxt" GridPane.columnIndex="1" GridPane.rowIndex="2" />
            <TextField fx:id="mainDishTxt" GridPane.columnIndex="1" GridPane.rowIndex="3" />
            <ComboBox fx:id="mealDivSelect" prefWidth="150.0" GridPane.columnIndex="1" GridPane.rowIndex="1" />
            <Button fx:id="saveBtn" mnemonicParsing="false" text="登録" GridPane.columnIndex="1" GridPane.rowIndex="5" />
            <TextArea fx:id="sideDishesTxt" prefHeight="49.0" prefWidth="391.0" GridPane.columnIndex="1" GridPane.rowIndex="4" />
         </children>
      </GridPane>
   </children>
   <padding>
      <Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
   </padding>
</VBox>

ここでのポイントは、

それぞれの入力項目と登録ボタンにつけた「fx:id」属性。

これをClojure側で参照してイベント処理したり入力値を取得したりする。

FXML側でイベント定義もできるのだけど、

今回はClojure側で処理しやすいようにレイアウト定義だけを行い、

イベントの設定はClojure側で行った。

データ登録用テーブルを作成する

テーブルは下記のように定義する。

サイドディッシュは複数登録できるように

PostgreSQLのJSONB型カラムにJSON配列として登録する。

create table meshilogs (
    id serial not null,
    day date not null default current_date,
    meal_div int not null,
    staple varchar(255) not null,
    main_dish varchar(255) not null,
    side_dishes jsonb default '[]'::jsonb,
    created timestamp not null default current_timestamp,
    primary key(id)
);

Leiningenプロジェクトを作成する

ここからメインのClojureコーディング。

食事履歴なので「meshilog」という名前で作成する。

project.clj

(defproject meshilog "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [cheshire "5.6.3"]
                 [korma "0.4.2"]
                 [org.clojure/java.jdbc "0.4.2"]
                 [org.postgresql/postgresql "9.4-1203-jdbc42"]]
  :resource-paths ["resources"] ; 追加1
  :aot :all ; 追加2
  :main ^:skip-aot meshilog.core
  :target-path "target/%s"
  :profiles {:uberjar {:aot :all}})

JavaFXのライブラリはJava8標準ライブラリなのでここでは特別な指定は不要。

コメントの「追加1」の行を追加しておくと、

resourcesフォルダに置いたfxmlファイルを相対的パスで取得できる。

「追加2」の行はmain関数からApplication/launchした時に

指定したcljファイルのstart関数を呼び出すために必要。

続いて、メインのcljファイル。

meshilog/core.clj

(ns meshilog.core
  (:gen-class
     :extends javafx.application.Application)
  (:import 
     (java.sql Date)
     (javafx.application Application)
     (javafx.event ActionEvent EventHandler)
     (javafx.scene Scene)
     (javafx.scene.control Button)
     (javafx.scene.layout StackPane)
     (javafx.stage Stage)
     (javafx.fxml FXMLLoader)
     (javafx.collections FXCollections))
  (:require [clojure.java.io :as io])
  (:require [cheshire.core :as cheshire])
  (:require [korma.db])
  (:require [korma.core :as kc]))

(korma.db/defdb db
                {:user "<DBユーザー名>"
                 :password "<DBパスワード>"
                 :subname "//localhost:5432/<DB名>"
                 :port "5432"
                 :subprotocol "postgresql"})
(kc/defentity meshilogs)

(defn -start
  [this ^Stage stage]
  (let [root (-> "app.fxml"
               (io/resource)
               (FXMLLoader/load))
        day-select (.lookup root "#daySelect")
        meal-div-select (.lookup root "#mealDivSelect")
        staple-txt (.lookup root "#stapleTxt")
        main-dish-txt (.lookup root "#mainDishTxt")
        side-dishes-txt (.lookup root "#sideDishesTxt")
        save-btn (.lookup root "#saveBtn")
        meal-divs {:朝食 1
                   :昼食 2
                   :夕食 3}] 
    (doto meal-div-select
      (.setItems (FXCollections/observableArrayList (map name (keys meal-divs)))))
    (.setOnAction save-btn (proxy [EventHandler] []
                             (handle [_] 
                                     (kc/insert meshilogs
                                                (kc/values {:day (Date/valueOf (.getValue day-select))
                                                         :meal_div (get meal-divs (keyword (.getValue meal-div-select)))
                                                         :staple (.getText staple-txt)
                                                         :main_dish (.getText main-dish-txt)
                                                         :side_dishes (kc/raw (str "'" (cheshire/generate-string (clojure.string/split (.getText side-dishes-txt) #"\n")) "'::jsonb"))}))
                                     (println "log saved"))))
    (doto stage
      (.setTitle "Meshilog")
      (.setScene (Scene. root 500 350))
      .show)))

(defn -main
  [& args]
  (Application/launch meshilog.core args))

基本はJavaでJavaFXプログラムを作成するのと同じ流れとなる。

main関数でApplication/launchメソッドを実行すると

JavaFXが立ち上がってstart関数が呼び出される。

start関数の中では、

  • fxmlファイルをロード
  • fx:idを指定してfxml内の入力項目、ボタンオブジェクトを取得
  • コンボボックス(meal-div-select)に選択肢を設定
  • ボタン(save-btn)にイベントハンドラをセット
  • ステージウィンドウを表示

という手順で処理を行っている。

今回はボタンのイベントハンドラでそのまま入力値を

データベースにinsertしてる。

ここまで書いて

lein run

すると、ウィンドウが立ち上がって食事履歴登録ができる。

lein uberjar

で実行可能jar化することで、

DBにアクセス可能なマシンなら

どこからでも実行できるアプリになる。

おわり

これで基本的な構成ができたので、

ここから食事履歴検索できるようにしたり、

メニューをマスタ化してカロリー計算したり色々応用できそう。

これからも年をとってうまくできなくなってきたことを

プログラムで補っていきたい。

JavaFX GUIプログラミング〈Vol.1〉

JavaFX GUIプログラミング〈Vol.1〉