💎

F#でMongoDBクライアント操作実験 - CSVファイルを読み込んでコレクションに保存

2024/10/25に公開

ここではF#MongoDBのクライアントを起動し、2種類のCSVファイルを読み込んで、それぞれの内容に合わせた型でドキュメントを生成しMongoDBのコレクションに保存する処理を実験的にやってみます。MongoDBの公式ドライバはC#向けとなっていますので、F#はサポート外でしょうが、同じ.NET環境でのプログラミングが可能なF#であえて実験してみようというわけです。

F#について

F#はC#と同じくMicrosoftの.NET環境で利用できるプログラミング言語です。C#と同じオブジェクト指向プログラミングとOCamlから派生した関数型プログラミングそれぞれの特徴を持っています。

MongoDBについて

MongoDBはリレーショナルデータベースとは異なる形式のデータを扱うNoSQLデータベースのひとつです。データベースにはコレクションと呼ばれるデータの保存場所が作成され、そこにJSONのような階層化された構造を持つデータであるドキュメントが保存されます。そして保存されたデータに対しては検索などの操作ができます。

ここではPCにインストール可能なMongoDB Community Editionを利用しますが、クラウドサービスのMongoDB Atlasも提供されています。

実行環境について

F#とMongoDBの実行環境についてはF#でMongoDBクライアント操作実験 - クライアントの起動からドキュメント操作までをご覧ください。クライアントの起動やドキュメントの保存についてもここで紹介しています。ただしこちらではドライバAPIの非同期メソッドを使用することにします。

使用するCSVファイルは以下の2つのWebサイトからダウンロードしたZipファイルを展開したものです。

  1. 日本 都道府県マスター データセット(mt_pref_all.csv.zipを展開したmt_pref_all.csv)
  2. 日本 都道府県マスター位置参照拡張 データセット(mt_pref_pos_all.csv.zipを展開したmt_pref_pos_all.csv)

これらのフォーマット仕様や利用規約はアドレス・ベース・レジストリから閲覧できます。

CSVデータと生成するドキュメントの確認

まず、これから読み込むCSVデータと生成するドキュメントを確認しておきます。
前項1.の都道府県マスターの先頭5行は以下の通りです。

CSVファイルの内容
$ head -n5 mt_pref_all.csv
lg_code,pref,pref_kana,pref_roma,efct_date,ablt_date,remarks
010006,北海道,ホッカイドウ,Hokkaido,1947-04-17,,
020001,青森県,アオモリケン,Aomori,1947-04-17,,
030007,岩手県,イワテケン,Iwate,1947-04-17,,
040002,宮城県,ミヤギケン,Miyagi,1947-04-17,,

最初にヘッダ行がありますので、これをドキュメントの項目名とします。ただしドキュメントとして保存するのは左から5項目までとします。そのため、ドキュメントの型は以下のようにします。

ドキュメントの型(都道府県)
type MtPref = {
    [<BsonId>]
    id: ObjectId
    lg_code: string
    pref: string
    pref_kana: string
    pref_roma: string
    efct_date: DateTime    // MongoDB内ではUTC (ToLocalTime可)
}

なおefct_dateはCSVファイルでは文字列ですが、ドキュメントを生成する際にSystem.DateTimeに変換することにします。ただし、これをMongoDBに保存するときはUTCとなるため、注意してください。

そして前項2.の都道府県マスター位置参照拡張の先頭5行は以下の通りです。

CSVファイルの内容
$ head -n5 mt_pref_pos_all.csv
lg_code,rep_lon,rep_lat,rep_srid,rep_scale,plygn_fname,plygn_kcode,plygn_fmt,plygn_srid,plygn_scale
010006,141.347906782,43.0639406375,EPSG:6668,,,,,,
020001,140.740087,40.824338,EPSG:6668,,,,,,
030007,141.152592,39.703647,EPSG:6668,,,,,,
040002,140.871846,38.268803,EPSG:6668,,,,,,

こちらも最初のヘッダ行をドキュメントの項目名とし、ドキュメントとして保存するのは左からの3項目とします。そのためドキュメントの型を以下のように定義します。

ドキュメントの型(都道府県位置情報)
type MtPrefPos = {
    [<BsonId>]
    id: ObjectId
    lg_code: string
    rep_lon: decimal
    rep_lat: decimal
}

なお、rep_lonrep_latは小数点以下の値を正確に数値に反映させるためdecimal型にしました。

CSVファイルを読み込んでリストを生成

CSVファイルの内容はシンプルなカンマ区切りなので、ファイルから1行ずつ読み込んて、それをカンマで区切った文字列のリストを生成します。生成後のデータは以下のようなイメージです。

CSVファイルからを読み込んで文字列のリストを生成
[
 ["010006"; "北海道"; "ホッカイドウ"; "Hokkaido"; "1947-04-17"; ""; ""];
 ["020001"; "青森県"; "アオモリケン"; "Aomori"; "1947-04-17"; ""; ""];
 ["030007"; "岩手県"; "イワテケン"; "Iwate"; "1947-04-17"; ""; ""];
 ["040002"; "宮城県"; "ミヤギケン"; "Miyagi"; "1947-04-17"; ""; ""];
 ["050008"; "秋田県"; "アキタケン"; "Akita"; "1947-04-17"; ""; ""];
 ..........
]

この処理は以下の関数で行うことにします。ポイントは関数内部で定義しているparseLineItemsStreamReader.ReadLineメソッドの実行結果に基づいて処理を振り分けているところです。これはrecにより再帰関数として定義されていてファイルを読み込み終えるまで繰り返し実行されます。

CSVファイルを読み込んで文字列のリストを生成
// getLineItems: file_name: 'a -> string list list
let getLineItems file_name =
    // ファイルのパス
    let file_path = $"/path/to/{file_name}.csv"
    // ファイルを1行ずつ読み込み、文字列をカンマ区切りで分割したリストを生成する関数(ファイルを読み終えるまで実行)
    let rec parseLineItems (reader: StreamReader) =
        match reader.ReadLine() with
        | null -> []
        | "" -> []
        | s -> (Array.toList (s.Split(','))) :: parseLineItems reader
    // ファイルの読み込み開始
    let file = new StreamReader(file_path)
    // ヘッダ行を読み飛ばす
    let _ = file.ReadLine()
    // リストの生成
    let line_items = parseLineItems file
    // ファイルを閉じる
    file.Close()
    // 関数の実行結果
    line_items

// 使用例
let line_items = getLineItems "mt_pref_all"

ドキュメントの生成

文字列のリストを用意したら、それをもとにドキュメントを生成します。それぞれのCSVファイルごとにドキュメントの型が違いますので、それぞれ型に合わせて生成処理を行います。

文字列のリストからドキュメント生成
// 都道府県マスターのドキュメント生成
"mt_pref_all"
|> getLineItems
|> List.map (fun items ->
    {
        id = ObjectId.GenerateNewId()   // idを生成
        lg_code = items[0]      // 文字列リストをレコードの各項目に設定
        pref = items[1]
        pref_kana = items[2]
        pref_roma = items[3]
        efct_date = DateTime.Parse items[4] // 日付を文字列からDateTimeに変換
    })
   // 生成したドキュメントをコレクションに保存

// 都道府県マスター位置情報拡張のドキュメント生成
"mt_pref_pos_all"
|> getLineItems
|> List.map (fun items ->
    {
        id = ObjectId.GenerateNewId()   // idを生成
        lg_code = items[0]      // 文字列リストをレコードの各項目に設定
        rep_lon = decimal items[1]  // 文字列からdecimal型に変換
        rep_lat = decimal items[2]
    })
   // 生成したドキュメントをコレクションに保存

ドキュメントをコレクションに保存

ドキュメントを生成したらそれをMongoDBのコレクションに保存します。コレクションはCSVファイルごとに用意することにします。ドキュメントを保存するときは非同期処理を行うためInsertManyAsyncメソッドを実行します。これによりTaskオブジェクト[1]を得られるため、Waitメソッドで終了を待ちます。

生成したドキュメントょコレクションに保存
#if INTERACTIVE
#r "nuget: MongoDB.Driver"      // 対話型実行環境(dotnet fsi)のとき実行
#endif

open System
open System.IO
open System.Text.Json
open MongoDB.Driver
open MongoDB.Bson
open MongoDB.Bson.Serialization.Attributes

// ドキュメントの型 - 都道府県マスター
type MtPref = {
    // ..........
}

// ドキュメントの型 - 都道府県マスター位置情報拡張
type MtPrefPos = {
    // ..........
}

// CSVファイルから文字列のリストを生成
let getLineItems file_name =
    // ..........
}

// MongoDBクライアントを起動
let client = new MongoClient()

// データベースを取得
let db_name = "fsmongo"
let db = client.GetDatabase(db_name)

// CSVファイル(ドキュメント)ごとにコレクションを取得
let mt_pref_all = db.GetCollection<MtPref>("mt_pref_all")
let mt_pref_pos_all = db.GetCollection<MtPrefPos>("mt_pref_pos_all")

// 都道府県マスターを読み込んでコレクションに保存
let task1 = "mt_pref_all"
            |> getLineItems

            // ..........

            |> mt_pref_all.InsertManyAsync
task1.Wait()

// 都道府県マスター位置情報拡張を読み込んでコレクションに保存
let task2 = "mt_pref_pos_all"
            |> getLineItems

            // ..........

            |> mt_pref_pos_all.InsertManyAsync
task2.Wait()

printfn $"mt_pref_allに{mt_pref_all.EstimatedDocumentCountAsync().Result}件のドキュメントが保存されました"
printfn $"mt_pref_pos_allに{mt_pref_pos_all.EstimatedDocumentCountAsync().Result}件のドキュメントが保存されました"
結果
mt_pref_allに47件のドキュメントが保存されました
mt_pref_pos_allに47件のドキュメントが保存されました

ドキュメントの検索とJSON文字列化

ドキュメントが保存できたかを確認するため、まず都道府県マスターのコレクションから1件ドキュメントを検索します。

ドキュメントの検索(都道府県マスター)
let tokyo = mt_pref_all.FindAsync<MtPref>(
                Builders<MtPref>.Filter.Eq("pref", "東京都")
            ).Result.FirstAsync<MtPref>().Result

そして検索されたドキュメントをJSON文字列化してみます。その方法は.NET APIのJsonSerializer[2]とMongoDBドライバのToJsonメソッド[3]がありますが、結果が異なりますので注意が必要です。

JSON文字列化(.NET API)
tokyo |> JsonSerializer.Serialize |> printfn "%s"
結果(実際は1行、空白なし)
{
    "id":{
        "Timestamp":1729764391,
        "CreationTime":"2024-10-24T10:06:31Z"
    },
    "lg_code":"130001",
    "pref":"\u6771\u4EAC\u90FD",
    "pref_kana":"\u30C8\u30A6\u30AD\u30E7\u30A6\u30C8",
    "pref_roma":"Tokyo",
    "efct_date":"1947-04-16T15:00:00Z"
}
JSON文字列化(MongooDBドライバ)
tokyo.ToJson() |> printfn "%s"
結果(実際は1行、カンマの次や括弧の次に空白あり)
{
    "_id" : { "$oid" : "671a00075738daa541aff736" },
    "lg_code" : "130001",
    "pref" : "東京都",
    "pref_kana" : "トウキョウト",
    "pref_roma" : "Tokyo",
    "efct_date" : { "$date" : { "$numberLong" : "-716720400000" } }
}

都道府県マスター位置情報拡張のほうも同様に検索とJSON文字列化を実行すると以下のようになります。

ドキュメントの検索とJSON文字列化
// ドキュメントの検索(都道府県マスター位置情報拡張)
let tokyo_pos = mt_pref_pos_all.FindAsync<MtPrefPos>(
                    Builders<MtPrefPos>.Filter.Eq("lg_code", "130001")
                ).Result.FirstAsync<MtPrefPos>().Result
// JSON文字列化(.NET API)
tokyo_pos |> JsonSerializer.Serialize |> printfn "%s"
// JSON文字列化(MongooDBドライバ)
tokyo_pos.ToJson() |> printfn "%s"
結果
// JSON文字列化(.NET API)
{
    "id":{
        "Timestamp":1729765156,
        "CreationTime":"2024-10-24T10:19:16Z"
    },
    "lg_code":"130001",
    "rep_lon":139.691717,
    "rep_lat":35.689568
}

// JSON文字列化(MongooDBドライバ)
{
    "_id" : { "$oid" : "671a03049d71be6811f403d5" },
    "lg_code" : "130001",
    "rep_lon" : { "$numberDecimal" : "139.691717" },
    "rep_lat" : { "$numberDecimal" : "35.689568" }
}

まとめ

  • CSVファイルの読み込みは再帰関数(rec)を使用した
  • 文字列のリストをもとにList.mapでドキュメントを生成した
  • ドキュメントの保存は非同期処理(InsertManyAsync)で行った
  • ドキュメントの検索も非同期処理(FindAsync, FirstAsync)で行った
  • ドキュメントのJSON文字列化には.NET API(JsonSerializer)とMongoDBドライバ(ToJsonメソッド)が実行できるが、両者は結果が異なるため注意が必要
脚注
  1. Taskクラス ↩︎

  2. JsonSerializer クラス ↩︎

  3. Method ToJson ↩︎

Discussion