ある案件でFileMaker Server上のデータを検索するプログラムを開発する機会があり、その備忘録として以下に記します。
FileMaker Serverの構築
環境を汚したくないので、Vagrant+VirtualBoxでゲストマシンを作成し、バージョン19で新登場したLinux版をインストールします。
ソフト | バージョン |
---|---|
FileMaker Server | 19.2.1.23 |
Vagrant | 2.2.14 |
vagrant-hostmanager | 1.8.9 |
VirtualBox | 6.1.18 |
CentOSイメージ | centos/7 (virtualbox, 2004.01) |
ゲストマシンの作成
def read_ip_address(machine)
command = "ip a | grep 'inet' | grep -v '127.0.0.1' | cut -d: -f2 | awk '{ print $2 }' | cut -f1 -d /"
result = ""
begin
machine.communicate.sudo(command) do |type, data|
result << data if type == :stdout
end
rescue
result = "# NOT-UP"
end
result.chomp.split("\n").select { |hash| hash != "" }[1]
end
Vagrant.configure("2") do |config|
config.vm.box = "centos/7"
config.vm.provider "virtualbox" do |vb|
vb.memory = "2048"
end
config.hostmanager.enabled = true
config.hostmanager.manage_host = true
config.hostmanager.manage_guest = true
config.hostmanager.ignore_private_ip = false
if Vagrant.has_plugin?("HostManager")
config.hostmanager.ip_resolver = proc do |vm, resolving_vm|
read_ip_address(vm)
end
end
config.vm.network "private_network", type: "dhcp"
config.vm.hostname = "test-fms"
config.vm.provision "file", source: "fms_19.2.1.23.zip", destination: "$HOME"
config.vm.provision "file", source: "LicenseCert.fmcert", destination: "$HOME"
end
FileMaker Serverのインストール媒体をプロビジョニングでゲストマシン内の/vagrantに配置しました。
また見様見真似でvagrant-hostmanagerを使い、ホストマシン側のhostsファイルに、ゲストマシンのホスト名とDHCPで割当てられたIPアドレスが反映されるようにしました。
これをvagrant upコマンドで起動しました。
FileMaker Serverのインストール
vagrant sshコマンドでゲストマシン内に入り、インストール作業を行います。
$ cd /vagrant
$ sudo yum install -y unzip centos-release-scl
$ unzip fms_19.2.1.23.zip
$ sudo yum install -y filemaker_server-19.2.1-23.x86_64.rpm
予めcentos-release-sclパッケージを追加しているのは、FileMaker Serverのインストール中にhttpd24とか見つからないと怒られるのを防ぐためです。
そしてパッケージのインストールが進むと途中で、以下の情報の入力を求められます。
- ライセンスに同意するか?
- インストールするのはServerか?WebDirect Worker?
- Admin Consoleのユーザ名とパスワード。
- パスワードリセット用の4桁の数字。
これを適宜入力すると、インストールは成功するのですが、HTTPサーバの起動は失敗したと警告が出て、Admin Consoleにアクセスできません。
Claris FileMaker Server Admin Console account is set up successfully.
HTTP Server has not started yet, wait for 2 seconds.
HTTP Server has not started yet, wait for 2 seconds.
HTTP Server has not started yet, wait for 2 seconds.
HTTP Server has not started yet, wait for 2 seconds.
HTTP Server has not started yet, wait for 2 seconds.
Warning! Failed to start HTTP server, please reboot the system.
しかし、言われたとおりにゲストマシンを再起動すればAdmin Consoleにアクセスできるので、ここでは深く追求できないので、しません。
FileMaker Serverの設定
デフォルトでJDBC接続が無効なので、Admin Consoleで設定の変更が必要です。
- インストールしたままだと、FileMaker ServerはSSLにClarisの自己証明書を使うので、ブラウザがAdmin Consoleを表示してくれません。本来はFileMaker Server内部のApache HTTP Serverが使う証明書を差し替える必要があります。
今回はホストとゲスト間だけで通信するので、ホスト側でClarisの自己証明書を信頼する設定を行いました。また、Admin Consoleに初回ログイン時に表示されるセキュリティ設定も[Claris デフォルト証明書を使用]で進めました。
ホスト側からhttps://test-fms:16000
でAdmin Consoleにログインし、[コネクタ]-[ODBC/JDBC]を選択し、[ODBC/JDBC]を有効にしました。
FileMaker ファイルのアップロード
FileMaker Pro 19でFileMaker ファイルを作成します。ここでは[ファイル]-[新規作成]で開かれた画面で[タスク]という雛形ファイルを作成しました。[ファイル]-[管理]-[データベース]で表示される画面で[リレーションシップ]をみると、こんなテーブル関係です。
これを[ファイル]-[共有設定]-[ホストにアップロード]を選択し、Admin(完全アクス権)へのパスワードを設定し(設定しないとアップロードできない)、アップロード先のホストを追加し、
ホストにサインインして、ファイルをホストにアップロードしました。
アップロードしたファイルで開き直したら、ファイル自体にもJDBC接続の設定があるので[ファイル]-[共有設定]-[ODBC/JDBCを有効にする]で、[ODBC/JDBC共有]をオンにし、このファイルにアクセスできるユーザを設定しました。
- なお上記の手順を入れ替えて、アップロード前にODBC/JDBC設定を行うと、アップロード後のファイルが「拡張アクセス権が無効にされています」と言われて開けない現象に遭遇しました。その場合はおそらく[ファイル]-[管理]-[セキュリティ]の詳細設定で、拡張アクセス権のfmappを編集してあげる必要がありそうです。
JDBCでアクセスする
ようやく本題ですが、まずはFileMakerファイルのテーブルがどう見えるのか確認してみます。
fun main(args: Array<String>) {
Class.forName("com.filemaker.jdbc.Driver")
DriverManager.getConnection("jdbc:filemaker://test-fms/タスク", "admin", "sample123")
.use { con ->
val dmd = con.metaData
dmd.getTables(null, null, "%", null).use { rs ->
while (rs.next()) {
println("--- Table ---")
val catalogName = rs.getString("TABLE_CAT")
println("カタログ名=$catalogName")
val schemaName = rs.getString("TABLE_SCHEM")
println("スキーマ名=$schemaName")
val tableName = rs.getString("TABLE_NAME")
println("デーブル名=$tableName")
dmd.getColumns(catalogName, schemaName, tableName, null).use { columnRs ->
while (columnRs.next()) {
println("カラム名=${columnRs.getString("COLUMN_NAME")}, java.sql.Types=${columnRs.getString("DATA_TYPE")}, 型名=${columnRs.getString("TYPE_NAME")}")
}
}
}
}
}
}
実行結果の抜粋は以下の通り。
--- Table ---
カタログ名=null
スキーマ名=null
デーブル名=タスク
カラム名=タスク, java.sql.Types=12, 型名=Text
カラム名=期限, java.sql.Types=91, 型名=Date
・
・
カラム名=修正情報タイムスタンプ, java.sql.Types=93, 型名=Timestamp
カラム名=予定工数, java.sql.Types=8, 型名=Number
--- Table ---
カタログ名=null
スキーマ名=null
デーブル名=割り当て
・
・
--- Table ---
カタログ名=null
スキーマ名=null
デーブル名=担当者
カラム名=写真, java.sql.Types=-4, 型名=Container
・
カラム名=名前, java.sql.Types=12, 型名=Text
・
--- Table ---
カタログ名=null
スキーマ名=null
デーブル名=添付ファイル
・
・
これで気づいた点は以下のとおりです。
-
作成したFileMakerファイル名はタスク.fmp12でアップロードしたので、JDBC URLのデータベース名に該当する部分はタスクと日本語のままです。接続に使用するユーザ名とパスワードは、FileMakerファイルに対して設定したものを使います。
-
カタログ名、スキーマ名はありません。
-
テーブル名やカラム名も日本語のままです。
-
カラムの型は以下の関係でした。なおタスク.fmp12には数値のカラムが無かったので予定工数というカラム名で追加しています。
FileMakerのタイプ java.sql.Types テキスト VARCHAR 数値 DOUBLE 日付 DATE タイムスタンプ TIMESTAMP オブジェクト LONGVARBINARY -
担当者テーブルの名前カラムは、名と姓カラムの値を結合た値になるFileMakerの計算というタイプで、JDBCでもカラムとして見えてます。
次はデータの入出力を試します。上記のコードに以下を追加して実行して、正常に終了しました。
con.prepareStatement("""insert into "タスク" ("タスク") values (?)""").use { statement ->
statement.setString(1, "のんびりする")
statement.executeUpdate()
}
con.prepareStatement("""insert into "担当者" ("名", "姓") values (?, ?) """).use { statement ->
statement.setString(1, "シャア")
statement.setString(2, "アズナブル")
statement.executeUpdate()
}
dmd.getTables(null, null, "%", null).use { rs ->
while (rs.next()) {
val tableName = rs.getString("TABLE_NAME")
println("--- Table: $tableName ---")
con.createStatement().use { statement ->
statement.executeQuery("""select * from "$tableName"""").use { rs ->
while (rs.next()) {
(1..rs.metaData.columnCount).forEach { columnIndex ->
println(""" ${rs.metaData.getColumnName(columnIndex)} = ${rs.getObject(columnIndex)} """)
}
}
}
}
}
}
実行結果と考察は以下のとおりです。
--- Table: タスク ---
タスク = のんびりする
期限 = null
説明 = null
ステータス = null
カテゴリー = null
完了日 = null
優先度 = null
主キー = 35E9D635-98CA-45FB-9A3C-E8345856EB02
作成者 = admin
修正者 = admin
作成情報タイムスタンプ = 2021-02-23 06:56:44.0
修正情報タイムスタンプ = 2021-02-23 06:56:44.0
予定工数 = 0.0
--- Table: 割り当て ---
--- Table: 担当者 ---
名 = シャア
姓 = アズナブル
写真 = null
敬称 = null
会社 = null
グループ = null
電話 = null
電子メール = null
名前 = シャア アズナブル
イニシャル = ア
主キー = 05FC1EE3-7F01-4B79-90D3-2B45BA93E070
作成者 = admin
修正者 = admin
作成情報タイムスタンプ = 2021-02-23 07:12:45.0
修正情報タイムスタンプ = 2021-02-23 07:12:45.0
--- Table: 添付ファイル ---
- 日本語のテーブル名やカラム名は"で括る必要がありました。
- 括らないと
com.filemaker.jdbc.FMSQLException: [FileMaker][FileMaker JDBC] FQL0001/(1:15): There is an error in the syntax of the query.
が発生しました。
- 括らないと
- ステートメントもプリペアード・ステートメントも正常に動作した。
- 数字タイプである予定工数カラムは何故かnullにならなかった。(後述)
- 計算タイプの名前カラムに値が反映されている。
- なお計算タイプのカラムに値を設定しようとすると
com.filemaker.jdbc.FMSQLException: [FileMaker][FileMaker JDBC] (201): Cannot modify field
が発生しました。
- なお計算タイプのカラムに値を設定しようとすると
Hibernateでアクセスする
結論から言うと、断念しました。
こちらにFileMaker用のDialectのサンプルがあり、それを利用して試してみたのですが。。。
@Entity
@Table(name = """"タスク"""")
data class Task(
@Id
@Column(name = """"主キー"""")
var id: String,
@Column(name = """"タスク"""")
val taskName: String
)
interface TaskRepository : JpaRepository<Task, String>
@Controller
@SpringBootApplication
class HibernateApplication(val taskRepository: TaskRepository) : CommandLineRunner {
override fun run(vararg args: String?) {
taskRepository.findAll()
}
}
fun main(args: Array<String>) {
runApplication<HibernateApplication>(*args)
}
spring:
datasource:
url: jdbc:filemaker://test-fms/タスク
driver-class-name: com.filemaker.jdbc.Driver
username: admin
password: sample123
hikari:
connection-test-query: SELECT p.* FROM FileMaker_Tables p
jpa:
properties:
hibernate:
dialect: nl.keates.filemaker.hibernate.dialect.FileMakerDialect
logging:
level:
org:
hibernate:
SQL: DEBUG
type: TRACE
2021-02-24 11:34:40.735 DEBUG 80682 --- [ restartedMain] org.hibernate.SQL : select task0_."主キー" as 主キー1_0_, task0_."タスク" as タスク2_0_ from "タスク" task0_
2021-02-24 11:34:40.753 WARN 80682 --- [ restartedMain] com.zaxxer.hikari.pool.ProxyConnection : HikariPool-1 - Connection com.filemaker.jdbc3.J3Connection@20551bea marked as broken because of SQLSTATE(08007), ErrorCode(27026)
com.filemaker.jdbc.FMSQLException: [FileMaker][FileMaker JDBC] FQL0001/(1:24): There is an error in the syntax of the query.
Hibernateが生成するSQLの別名が"で括られないからでしょうか?
MyBatis Generatorでアクセスする
MyBatisならSQL直指定なので問題ないだろうと思い、ならばMyBatis Generatorで生成したコードでどこまで労力をミニマムにできるか確認してみます。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
<context defaultModelType="flat" id="mybatis-builder" targetRuntime="MyBatis3Kotlin">
<property name="javaFileEncoding" value="UTF-8"/>
<property name="autoDelimitKeywords" value="true"/>
<jdbcConnection connectionURL="jdbc:filemaker://test-fms/タスク" driverClass="com.filemaker.jdbc.Driver"
password="sample123" userId="admin"/>
<javaTypeResolver>
<property name="useJSR310Types" value="true"/>
</javaTypeResolver>
<javaModelGenerator targetPackage="sample.db" targetProject="src/generated/kotlin" >
<property name="trimStrings" value="true"/>
</javaModelGenerator>
<javaClientGenerator targetPackage="sample.db" targetProject="src/generated/kotlin" type="XMLMAPPER" />
<table tableName="%" delimitIdentifiers="true" delimitAllColumns="true">
<generatedKey column="主キー" identity="true" sqlStatement="JDBC"/>
</table>
</context>
</generatorConfiguration>
- targetRuntimeとして、いつの間にかできてたMyBatis3Kotlinを指定しました。これによりsqlMapGeneratorの指定は不要になります。
- autoDelimitKeywordsプロパティをtrueにするとカラム名やテーブル名が"で括られるようになります。
- 生成されるコードはsrc/generated/kotlinと、src/main/kotlinとは別フォルダに出力させました。
<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<!-- 省略 -->
<dependencies>
<!-- 省略 -->
<dependency>
<groupId>org.mybatis.dynamic-sql</groupId>
<artifactId>mybatis-dynamic-sql</artifactId>
<version>1.2.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 省略 -->
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<!-- 省略 -->
<executions>
<execution>
<id>compile</id>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
<sourceDir>${project.basedir}/src/generated/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
<!-- 省略 -->
</executions>
</plugin>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.4.0</version>
<configuration>
<configurationFile>${basedir}/mybatis-generator-config.xml</configurationFile>
<overwrite>true</overwrite>
</configuration>
<dependencies>
<dependency>
<groupId>com.filemaker</groupId>
<artifactId>fmjdbc</artifactId>
<version>19.2.1.23</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>
- kotlin-maven-pluginにsrc/generated/kotlinもソースフォルダとして指定しました。
- アーティファクトの依存関係に、生成したコードが使用するmybatis-dynamic-sqlを追加しました。
- mybatis-generator-maven-pluginを追加し、
mvn mybatis-generator:generate
でコードが生成されるようにしました。
生成されたコードは日本語を含んだクラス名だったり、日本語だけのプロパティ名だったりと、IntelliJ上で「an identifier に非 ASCII 文字があります」と表示されるのは気になりますが、このまま進めます。本番だったらカラム名とプロパティ名のマッピングを定義することになるかと。
あと、これはMyBatis Generator側の問題だと思うのですが、テーブルに、テーブルと同じ名前のカラムが存在すると、生成されたコードがエラーになっているので微修正しました。
import quo.vadis.megasys.sample.db.タスクDynamicSqlSupport.タスク as TaskTable
・
・
import quo.vadis.megasys.sample.db.タスクDynamicSqlSupport.タスク.タスク
・
・
fun タスクMapper.count(completer: CountCompleter) =
countFrom(this::count, /*タスク->*/TaskTable, completer)
そして以下のコードを実行すると、、、
@Controller
@SpringBootApplication
class MyBatisApplication(val taskMapper: タスクMapper) : CommandLineRunner {
override fun run(vararg args: String?) {
taskMapper.select {
allRows()
}.forEach {
println(it)
}
}
}
fun main(args: Array<String>) {
runApplication<MyBatisApplication>(*args)
}
残念ながらFileMakerのJDBCドライバでエラーになってしまいました。残念。
Caused by: com.filemaker.jdbc.FMSQLException: [FileMaker][FileMaker JDBC] This method is not yet implemented.
at com.filemaker.jdbc.Driver.notImplemented(Unknown Source) ~[fmjdbc-19.2.1.23.jar:na]
at com.filemaker.jdbc3.CommonJ3ResultSet.isClosed(Unknown Source) ~[fmjdbc-19.2.1.23.jar:na]
at com.zaxxer.hikari.pool.HikariProxyResultSet.isClosed(HikariProxyResultSet.java) ~[HikariCP-3.4.5.jar:na]
at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:352) ~[mybatis-3.5.6.jar:3.5.6]
MyBatisが呼び出すResultSet#isClosed()が実装されていないとこと。しかたないのでJDBCドライバのラッパーを作って回避しました。
import java.sql.ResultSet as JavaSqlResultSet
import java.sql.PreparedStatement as JavaSqlPreparedStatement
import java.sql.Connection as JavaSqlConnection
import java.util.*
class Driver : com.filemaker.jdbc.Driver() {
override fun connect(p0: String?, p1: Properties?): Connection {
return Connection(super.connect(p0, p1))
}
}
class Connection(private val conn: JavaSqlConnection) : JavaSqlConnection by conn {
override fun prepareStatement(sql: String?): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql))
}
override fun prepareStatement(sql: String?, resultSetType: Int, resultSetConcurrency: Int): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql, resultSetType, resultSetConcurrency))
}
override fun prepareStatement(sql: String?, columnNames: Array<out String>?): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql, columnNames))
}
override fun prepareStatement(sql: String?, columnIndexes: IntArray?): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql, columnIndexes))
}
override fun prepareStatement(sql: String?, autoGeneratedKeys: Int): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql, autoGeneratedKeys))
}
override fun prepareStatement(
sql: String?,
resultSetType: Int,
resultSetConcurrency: Int,
resultSetHoldability: Int
): JavaSqlPreparedStatement {
return PreparedStatement(conn.prepareStatement(sql, resultSetType, resultSetConcurrency, resultSetHoldability))
}
}
class PreparedStatement(private val statement: JavaSqlPreparedStatement): JavaSqlPreparedStatement by statement {
override fun executeQuery(): JavaSqlResultSet {
return ResultSet(statement.executeQuery())
}
override fun executeQuery(sql: String?): JavaSqlResultSet {
return ResultSet(statement.executeQuery(sql))
}
override fun getResultSet(): JavaSqlResultSet {
return ResultSet(statement.resultSet)
}
override fun getGeneratedKeys(): JavaSqlResultSet {
return ResultSet(statement.generatedKeys)
}
}
class ResultSet(private val result: JavaSqlResultSet): JavaSqlResultSet by result {
private var closed: Boolean = false
override fun close() {
result.close()
closed = true
}
override fun isClosed(): Boolean {
return closed
}
}
このラッパーを使うように修正すると正常終了するようになりました。
2021-02-24 14:57:16.477 DEBUG 84046 --- [ main] sample.db.タスクMapper.selectMany : ==> Preparing: select "タスク", "期限", "説明", "ステータス", "カテゴリー", "完了日", "優先度", "主キー", "作成者", "修正者", "作成情報タイムスタンプ", "修正情報タイムスタンプ", "予定工数" from "タスク"
2021-02-24 14:57:16.498 DEBUG 84046 --- [ main] sample.db.タスクMapper.selectMany : ==> Parameters:
2021-02-24 14:57:16.521 DEBUG 84046 --- [ main] sample.db.タスクMapper.selectMany : <== Total: 1
タスクRecord(タスク=のんびりする, 期限=null, 説明=null, ステータス=null, カテゴリー=null, 完了日=null, 優先度=null, 主キー=35E9D635-98CA-45FB-9A3C-E8345856EB02, 作成者=admin, 修正者=admin, 作成情報タイムスタンプ=null, 修正情報タイムスタンプ=null, 予定工数=null)
数値(予定工数)が何故かnull
実はプリミティブ型のカラムはResultSet#wasNull()で、本当はnullか判定する仕様だったんですね!
だからnullが正しい訳です。
タイムスタンプが何故かnull
今回は日時にJSR310を使用するようにMyBatis Generatorに指定したため、タイムスタンプのカラムではResultSetからLocalDateTimeTypeHandlerで値の取得を行います。LocalDateTimeTypeHandlerではJava 1.7で追加された
rs.getObject(columnName, LocalDateTime.class);
で値を取得するのですが、FileMakerのJDBCドライバでこのメソッドは残念ながらnullしか返しません。
そこでJDBCドライバラッパーを拡張してみました。
class ResultSet(private val result: JavaSqlResultSet): JavaSqlResultSet by result {
・
・
override fun <T : Any?> getObject(columnIndex: Int, type: Class<T>): T? {
return when(type) {
LocalDateTime::class.java -> {
return result.getTimestamp(columnIndex)?.toLocalDateTime() as T?
}
LocalDate::class.java -> {
return result.getDate(columnIndex)?.toLocalDate() as T?
}
LocalTime::class.java -> {
return result.getTime(columnIndex)?.toLocalTime() as T?
}
else -> result.getObject(columnIndex, type)
}
}
override fun <T : Any?> getObject(columnLabel: String, type: Class<T>): T? {
return getObject(this.findColumn(columnLabel), type)
}
}
他にも色々と対応が必要ですが、一応は動きました。
所感
いろいろと癖はありますが、なんとかできそうな感触を得ることができました。
実際に開発したり、運用すると、他にも問題点はでてきそうですが、それは他のDBでもあることですし。
ただJDBCドライバに未実装が多いのは気になりますね。気が向いたらラッパーを公開してみても良いのですが、どこまでFileMakerと付き合うことになるか判らないので、今回はここまでで。
あとMyBatis Generatorの Kotlin DSLは面白そうですね。最近はJOOQに浮気してたのですが、MyBatisに戻っても良い気がしてきました。