BLE アドバタイズのパケット/iBeacon の詳細

なかなか、実際のプログラムコードに至りませんが、その前の前提を知っておいたほうがいいので少しずつ書いていきます。これ、Android や iOS で BLE ライブラリと使って Flutter とか React Native で作るときはさっくりと通していいのですが、m5Stack とか ESP32 とか使って自分で BLE 周りを制御しようとすると途端に必要な知識になっているので(知らないと落とし穴にはまる)、前提として知っておいたほうがいいです。

BLE パケットの Protorol Data Unit (PDU)

BLE パケット全体の大きさは、たかだか 47 バイト程度です。データ通信の場合はもっと長くなるのですが、ここで扱うのは BLE アドバタイズなので、非常に短いデータ量で済みます。データ量が少ないので、0.4 ms 以下という短い単位で送信/受信が可能です。

だから、受信しようと思えば Scan Window を広く取っておけば、いつでも BLE アドバタイズを受信できる確率は高くなります。ただし、それは概念的なので、実際的にはハードウェア的に Scan Window で待ち受けている時間とあ Scan Interval で受信できない時間が交互にやってきます。これ、バッテリーの問題なのか、ハードウェアの制限なのかはよくわからないのですが(つまり、無限に Scan Window を広げられるか私にはわかりません)、現実n BLE のハードウェアはそういう構造になっているということです。

さて、BLE パケットの内、データを乗せることができる Protocol Data Unit (PDU) の詳細をみていきます。

BLUETOOTH CORE SPECIFICATION Version 6.0 の Vol 6 Low Energy Controller の Part B LINK LAYER SPECIFICATION の 2 AIR INTERFACE PACKETS あたりから書いてあります。

全部で 4000 ページ位あるのですが、Core Specification 6.0 | Bluetooth® Technology Website https://www.bluetooth.com/specifications/specs/core60-html/ からダウンロードが可能です。通読するのは大変なので、適度にピックアップして読みます…が、この部分は PDU の部分だけじゃなくて、BLE パケット全体の仕様が書いているので、肝心のデータ部分がわかりません。多分、2.3.1.1 ADV_IND あたりの AdvData の部分のフォーマットだと思うのですが、これは後で調べ直し。

要は、BLE アドバタイズでも色々なヘッダーがあって種類があるのですが、iBeacon のようなフォーマットを送っている場合は ADV_IND の種類を流しておいて、その中のデータ = AdvData を送受信側でプロトコルとして決めるわけです。物理層/リンク層のもうひとつ上のレイヤーになります。

この AdvData の中身を決めているのが、Supplement to the Bluetooth Core Specification https://www.bluetooth.com/specifications/specs/core-specification-supplement-10/ です。これも PDF 形式でダウンロードができます。

この中の Part A DATA TYPES SPECIFICATION にプロトコル仕様が書いてあります。

これはサービス UUID の書き方ですね。

まあ、表だけ並べられても解り辛いので、いくつかのサンプルデータも載っています。

これはデバイス名をアドバタイズする場合です。

BLE のフォーマットはちょっと変わった形式になっていて、

  • データ長
  • データタイプなど
  • データ自身

という順番で並んでいます。データ長が1バイトと制限になるので(最大 255 バイト)とは思います。
上のデータを書き直すとこんな感じになります。

最初の 0x02 のように、フラグだけ並んでいる場合と、次の 0x0A のようにデバイス名のような可変長のデータが続く場合があります。デバイス名は特殊でデータ長が入っているのですが、大抵は Service UUID のように 16 バイトとか 4 バイトとか決まった長さのデータが続く場合が多いです。

なので、BLE アドバタイズ自身は Data types specification の書式に従っていれば自由に BLE アドバタイズのデータ送信が可能です。実際、Apple の iBeacon のフォーマットや、Google の Eddystone のフォーマットもこの書式に従っています。おそらく BLE 端末の機器メーカーも流しているのかはこれかな、と思うのですが、定かではありません。

BLE アドバタイズのフォーマットは自由に決められるということは解ったのですが、接触確認アプリのように既存の OS や BLE ライブラリを使う場合にはそれほど自由ではありません。と言いまうsか、Apple の場合は iBeacon フォーマットしか使えません。Android の場合は、もう少し自由に作れますが、iPhone との共同運用を考えると iBeacon フォーマットしか使えないというのが現実的な選択肢です。

もちろん、m5stack とか ESP32 を使て独自の BLE アドバタイズのフォーマットを使えば自由に作れます。Android の場合も多少の制限がありますが、Android 同士であれば結構自由に作れます。これは先行き解説していきます。

話を元に戻すと、FolkBears のコレクションレスモードの場合は、iBeacon のフォーマットを使っています。この iBeacon フォーマットは何処に記述されているのでしょうか?

https://developer.apple.com/ibeacon/ から Artwork and specifications をダウンロードして、Proximity Beacon Specification を開くと正式なものがあります。

これ、なかなか探してもわからなかったのですが、こんなところにあったんですね。

これだと解り辛いので、書籍や Wikipedia の iBeacon の記事を参考にするとわかりやすいです。

iBeacon – Wikipedia https://en.wikipedia.org/wiki/IBeacon

これを図に書き直すとこんな感じになります。

これが慣れないと難しいのですが、

  • 最初の3バイトは、BLE アドバタイズのヘッダー情報
  • 次の4バイトは、Maniufacturer Specific Data のデータタイプを示すための Length + 0xFF + Company ID (Apple の場合は 0x004C) が続く
  • 次の 2 バイトは、Apple 決めた iBeacon を示すための固定値 0x02 と 長さ(0x15)
  • 次の 16 バイトは、iBeacon の UUID
  • 次の 2 バイトは、Major
  • 次の 2 バイトは、Minor
  • 最後の 1 バイトは、Tx Power

という訳で2段階に分かれています。

BLE の規格としては、Manufacturer Specific Data を示す 0xFF のフラグと、その後の Company ID までで、その後は各社自由に作れます。自由に作れるといっても、アドバタイズのデータ全体が 31 バイトまでの制限なので、さらに小さくなります。

さらに、Company ID 自体は Bluetooth SIG が管理しているので、お金を払っている Apple 社は 16 ビット(2バイト)の Company ID を持っているのですが、実験的には 0xFFFF などを使わないといけません。
このあたりの細かいところは、後で ESP32 などで実装するときに詳しく解説する予定です。すくなくとも m5stack などの BLE ライブラリを使うと、かなり自由に BLE アドバタイズのフォーマットが作れるので iBeacon フォーマットにこだわる必要はありません。

iBeacon フォーマットの区切りがややこしいのですが、最初の Lnetgh + Flag の組みあわせは BLE 規格のほうで、 Manufacturer ID 以降の、Sub Type + Sub Length のほうの組み合わせは iBeacon 規格を出している Apple 独自の仕様です。ここで、Lenth と Type が逆になっているのはそのせいです。これは当悩んだのですが、つまりは内部規格ということです。

他にも Google の定義する AltBeacon フォーマットとか Eddystone フォーマットとかもありますが、これはまた別に解説します。同じ Beacon フォーマットとはいえ、ちょっとずつ違っているのは各社設定してしまっているからです。

とはいえ、Beacon タグからのデータは Apple だげが受信するものでもなく、Beacon タグの作成自体も Apple だけが作るわけでもありません。

  • 非 Apple 製の Beacon タグもある
  • 非 Apple 製の iBeacon 受信アプリもある

という事情があります。互換性というわけです。

iBeacon データを実測する

iBeacon フォーマット詳細がわかったところで実測をしてみましょう。

実測とはいえ、きちんとした iBeacon 受信機を作るところからスタートしないといけないのですが、ここでは私が以前したデータを使います。と、言いますか、ここからが問題があるのです。

iOS から発信された iBeacon データ

02011A1AFF4C00021590FA7ABEFAB6485EB7001A17804CAA13A6D0CFC1C5

これを先の表に従って分解していきます。

iBeacon UUID: 90FA7ABEFAB6485EB7001A17804CAA13 が取得できればひとまず ok です。
このデータ自体、Major や Minor、Tx Power も取得できるので、iBeacon フォーマットとしては正しいことがわかります。
実は、実測すると分かるのですが 3 バイト目のFlag データの 0x1A の部分は、iBeacon 発信機によって異なります。ここは BLE アドバタイズに仕様なので、先の iBeacon 仕様の例のように同じ値になるとは限りません。つまりは、内部的に Maniufacturer Specific Data の部分は同じ Apple の iBeacon の規格を使っているとしても前後の BLE のフラグは発信する機種/メーカーによって異なります。

これを Android が iBeacon データを発信したときのデータを見てみましょう。

1AFF4C00021590FA7ABEFAB6485EB7001A17804CAA13F15FA5E2C5

実は、先頭の 3バイト(02011A)がありません。

この現象は Android の org.altbeacon.beacon ライブラリと使ったときに発生します。

private fun startBeaconTransmission() {
    val beacon = Beacon.Builder()
        .setId1(SERVICE_UUID.toString()) // UUID
        .setId2(major.toString()) // Major (10進数文字列)
        .setId3(minor.toString()) // Minor (10進数文字列)
        .setManufacturer(0x004C) // Apple iBeacon のメーカーコード
        .setTxPower(-59) // 信号強度 (dBm)は仮設定
        .build()

    // val beaconParser = BeaconParser().setBeaconLayout(BeaconParser.ALTBEACON_LAYOUT)
    val beaconParser = BeaconParser().setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24")
    beaconTransmitter = org.altbeacon.beacon.BeaconTransmitter(context, beaconParser)
    beaconTransmitter?.startAdvertising(beacon, object : AdvertiseCallback() {
        override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
            Log.d("BeaconService", "iBeacon 発信開始")
        }
        override fun onStartFailure(errorCode: Int) {
            Log.e("BeaconService", "iBeacon 発信に失敗: $errorCode")
        }
    })
}

この iBeacon 形式のデータを iOS 側で受信させると、うまく受信できるので iBeacon 発信機としてうまく動いています。先頭部分の 02011A がなくても iBeacon フォーマットとしては問題ない、ということなのです。
ただし、この iBeacon データを、android.bluetooth.le.ScanFilter でフィルタリングしようとすると、うまくフィルタリングできない、という問題があります。なんだかよくよくわからないので、FolkBears では Maniufacturer Data の中身をシークしているのですが、ここは現在のところ回避策をとっているところです。

ひとまず、この BLE 規格の部分と iBeacon フォーマットを把握しておけば、BLE アドバタイズで受信したデータを解析できることがわかります。このあたり、m5stack や ESP32 で BLE アドバタイズを受信するときにも使える知識です。

カテゴリー: 開発, FolkBears |  BLE アドバタイズのパケット/iBeacon の詳細 はコメントを受け付けていません

BLE 近接通信データを使った二世代前のコンタクトトレースを探る

もともと、BLE による接触確認アプリに興味を持ったのは、アプリ自体が保持している近接データを駆使すれば、二世代前の接触者まで追跡できろうだろうと思ったわけですし、それをやると思ったんですよ。

コンタクト・トレーシング – Wikipedia https://ja.wikipedia.org/wiki/%E3%82%B3%E3%83%B3%E3%82%BF%E3%82%AF%E3%83%88%E3%83%BB%E3%83%88%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%B3%E3%82%B0

2020年当時に日本の保健所が逼迫していたのが、感染者の聞き取り調査の部分です。聞き取り自体がアナログでしかできないし、その追跡自体も人海戦術のようでした。基本的な感染症数理モデルの SIR 型モデルを知ったのもこの頃ですが、統計学のそれも興味の範囲ではあったけど、直接 IT 技術者として手が付けられそうなのは、この接触確認アプリの近接データの扱いです。

結論から先に言うと、Goolge/Apple の Exposure Notification API を使った場合には二世代前の接触者などを追跡することはできません。サーバーに集められるのは、Temporary Exposure Keys (TEK) と呼ばれる感染者の端末が生成した一時的な鍵情報なだけで、接触自体はアプリ内で確認することになるからです。
これは個人情報保護としてはいいのですが、先にあるように保健所のトレーシング業務を支援することは不可能なわけです。ある意味 EN API の設計の失敗(実用度の失敗でもありますが)はここにあると思っています。

俺の考える最強のコンタクトレース…というか、日本の保健所のヒアリングがこんな感じになっていたと思うのです。いわゆる、新型コロナウィルスの場合もインフルエンザの場合も、感染者が居て集団で感染するクラスターのパターンが顕著に感染が広がります。接触感染じゃななくて空気感染(詳しい事は省略したうえで)なのがミソです。学校や老後施設での集団感染のことです。結局のところ、集団でいたときにマスクで防護するのが一番効率がよいのですが、じゃあ、まだマスクが主要でなかったときに、クラスター感染をどうやって見つけるのか、というのが当初の問題にありました。

そこで、保健所でヒアリングをして、感染源であるクラスターを探して、そのクラスターに居た人に注意喚起をしよう、というのが目的した。

ただし、そのヒアリングが人力であったし、保健所の職員自体も足りなかったので非常に負担であったという事実があります。いまだと、感染自体の報告義務がなくなったので、ヒアリング自体がどうなっているか不明ではありますが。
ただし、将来的に同じパターンで感染が広がったときに「ヒアリング」という手段でよいのか? という問題があります。

IT 技術者としては、ここをなんとか自動化できるのではないか、と考えるわけです。

1. A で感染者が発覚する
2. A にヒアリングして、過去の B の集団が感染が疑われる
3. A にヒアリングして、最近の C の集団が感染が疑われる
4. B の感染疑いから、遡って C の感染疑いが発覚する
5. 過去の D の集団は、2 週間(感染期間)より前なので除外する
6. 未来の E の集団は、2 週間以内なので感染疑いとする
7. 未来の F の集団は、2 週間後なので除外する

基本的に感染者 A が発生したときは、その周辺の人達(家族や会社の同僚など)に注意喚起をするわけで、ちょっと前に合っていた B の集団に注意を喚起するわけです。
同時に、感染者 A がうろうろ歩き回ることを考えると、将来的に接触しそうな E に集団にも中期喚起が必要なわけです。まあ、新型コロナウィルスの場合は監禁ということになっていたので、うろうろ歩き回ってはいけないのですが。このあたりは、接触確認アプリの目的としてはどうなのでしょう、というところがあります。

で、実際のところ機能していたのは B の集団に対する注意喚起であって、感染者 A が感染登録をすると、B の集団の持っているスマホに「感染者に接触した可能性あります」という通知が送られるというのが EN API の基本的な仕組みです。
プライバシーの観点から、誰が感染したかどうかは分からないようになっています。が、「感染者に接触した可能性があります」という文言自体があまりにも曖昧なので、結果的にどう扱っていよいか分からなかったのが当時の実情です。結果的に「接触確認アプリが使えない」という批判を浴びたのもこういうところでしょう。

実際のところ、突き合わせは集団 B の人達が持っているスマホ内で照合が行われるので、集団 B の人達が感染者 A がその人であることを知ることはできません。この制限は、あったほうが良いかったのか、ないほうが良かったのかは不明ですが、EN API の設計としてはこうなっているわけです。

で、保健所でトレースをとっているのは、

1. 感染者 A が集団 B に感染させている可能性
2. 感染者 A が集団 B の誰かに感染させられた可能性

の二つがあります。新型コロナウィルスの場合、無症状状態があってその間に感染させている期間があるという前提となっているので(実はこれはインフルエンザでもあることが最近知られています)、感染者 A は自分が感染していることを知らずに、集団 B の誰かに感染させている可能性がある、という想定です。と、同時に、集団 B が既に感染をしていてその誰かからか感染させられたという可能性です。
これは、感染したという事実は、必ずしも保健所に報告されないことがあるためです。あるいは、無症状のまま感染している状態もあるので、集団 B には潜在的な感染者がいるという可能性を考えるわけです。

新型コロナウィルス自体は、人から人に伝播するわけですから、感染者 A が集団 B に感染させられたときには、その以前いた集団 C にも感染者がいる可能性があります。
こんな風に、感染の経路を保健所の職員がヒアリングして突き止めていたわけですが…果たして、接触確認アプリはどのくらい貢献できたでしょうか?

というか、EN API の設計上で、このトレースは取ることができたでしょうか? という疑問があります。

先に書いた通り、EN API の設計上、個人のスマホ内でしか照合をしないので、感染通知が出せるのは集団 B の人達に対してだけです。しかも、どの時間に誰に接触したかを知らせることはしないので(サーバーとクライアントのデータを照合すれば特定可能ではありますが…機能上できません)、感染者 A がうろついた場所でしか注意喚起ができません。いや、むしろ、注意喚起ばかりが大きくて、迷惑だったという事実があります。まあ、端的に言えば、役に立たなかったのです。

これは、EN API の設計に引きずられてしまったという要因もあります。

先に書いた通り、EN API の設計上、感染者 A がすれ違った集団 B の人達というあいまいな特定の仕方しかできないので、アプリ自体がどう工夫してもこの制限を超えることができないのです。

では、もし、EN API の設計を変えたとして、保健所のようなトレース(集団 B や集団 C までの範囲の特定、そして 集団 D には通知しないなど)ができる程度まで、データの照合ができるとしたら、どのような仕組みを考えればよいでしょうか? というのが FolkBears の課題であると私は思っています。

当然、EN API のようにプライバシーを守る必要があります。コンタクトトレースの場合を取る場合は、一番乱暴な方法としては GPS の位置情報をサーバーに送ってしまう、という方法があります。ですが、これはプライバシーの観点からは最悪です。感染者 A の行動履歴だけでなく、集団 B や C までが丸裸になってしまうからです。実際、GPS の位置情報を利用したアプリで、犯罪者の行動履歴を取ることもあります。

では、GPS の位置情報ではなく、単純に BLE を使って近接した(いわゆる iBeacon のように)だけを使って、どのくらいまで保健所のトレースと同じことができるでしょうか?

カテゴリー: 開発, FolkBears | BLE 近接通信データを使った二世代前のコンタクトトレースを探る はコメントを受け付けていません

BLE 物理層の詳細とスキャン頻度

BLE(Bluetooth Low Energy) を使う上で、アドバタイズ方式(iBeacon方式)と GATT(Generic Attribute Profile)を使ったコネクション方式があるわけですが、まずは近接しているデバイスを見つけないといけません。iBeacon の場合はスマホアプリから iBeacon の端末を見つけないといけないし、GATT の場合はペリフェラル側のワイヤレスイヤフォン等をスマホアプリから見つけないといけません。

店内にある iBeacon デバイスとか、目の前に接続しようとしているワイヤレスイヤフォンの場合は、アプリを立ち上げた後に “すぐそこにある端末” に接続するまでじっと待つというスタイルになります。これ、当たり前のように思えますが、最初のデバイスを見つける手順はどうなっているのでしょう? ってのが問題です。

この図では、「スマホ」から「iBeacon」に向かって矢印を向けていますが、実際は iBeacon 側から何等かのデータを発信していて、スマホ側のアプリが受け取るという形になります。

こんな風に、iBeacon 側で何度も発信しているものを、スマホ側でなんらかのタイミングで受け取れることができる、ってのが正しいです。

BLE 物理層(PHY) の仕様

BLE の物理層は、2.4 GHz 帯の ISM バンドを使っています。一般的に、広告チャンネル(アドバタイズ)とデータチャンネルの2種類に分かれています。

  • 広告チャンネル(アドバタイズ)が 37,38,39 の 3 チャンネル
  • データチャンネルが 0-36 の 37 チャンネル

合計 40 チャンネルを使っているので、BLE 通信をしているときに、たくさんの端末があってもデータ通信は大丈夫、という仕組みです。
ただし、この3つしかない広告チャンネルってのが問題で、BLE ペリフェラル(発信側)がアドバタイズを行うときには、この3つのチャンネルを順番に使って発信しています。これは、

  • iBeacon や ENA/FolkBears のように、コネクションレスでアドバタイズを行うとき
  • GATT のように、コネクションを確立する前のデバイスを見つけるとき

に重要になります。いや、先の例のように設置してある iBeacon デバイスを見つけるときとか、目の前にあるワイヤレスイヤフォンを見つけるときには問題ありません。スマホアプリを使って接続できるかなぁと暫く待てば、iBeacon もワイヤレスイヤフォンも見つかります。

普通の BLE デバイスの場合は、接続までの時間が 30 秒位あっても問題はありません。確かに店内設置のような iBeacon デバイスを発見するのに 30 秒位掛かると遅くて問題になるかもしれませんが、この発見までの時間は確率的に決まるものなので、早く見つかることもあれば遅く見つかることもあります。これは後述します。

また、接触データの交換をするにしても、最初に相手のデバイスを見つけないといけません。これは BLE のチャンネル自体は 40 チャンネルあってデータ送受信で競合しないようになっていますが、実は相手のデバイスを発見するための広告チャンネルを使うために、3 チャンネルしかないということです。
要は意外と少ないという話です。

さらに、接触確認アプリのようにできるだけ短時間で接触を確認したい場合には、発見までの時間が重要になります。いわゆる発見までの遅延があるわけです。

そういうわけで、接触確認アプリ(m5stack等で作れば接触確認デバイス)を作る場合には、発見までの遅延が重要になります。このあたり、なぜかというと、数秒ですれ違ったりする場合には確率的に相手のデバイスを発見できない可能性があるということです。

  • 広告チャンネルの 3 チャンネルで競合する
  • 発見までの遅延が確率的にある
  • 複数の BLE デバイスが集中する場合、発見しない可能性もある

ということを抑えておく必要があります。

パケットの構造

BLE のパケットの話を少し書いておきます。

最初、BLE データの衝突で、データ落ちがあると思っていたのですが、それはほとんどあり得ません、ということです。

BLE パケットは以下のような構造になっています。

  • Preamble: 1 byte PHY チャンネル識別用
  • Access Address: 4 byte 固定値 0x8E89BED6
  • Header: 2 byte パケット種別、長さ情報
  • Protocol Data Unit (PDU): 0-37 byte ペイロードデータ
  • CRC: 3 byte 誤り検出用

一般的に 32 バイトの BLE データと言っているのが、Protocol Data Unit の部分です。だいたい、データとして利用できる範囲は 31 byte 程度です。これが無線でデータとして飛んできます。周波数なので 0/1 のビット列で飛んでいき、これを受信してチェックするわけです。

データ長自体は、たかだか 47 バイト程度です。データ通信の場合はもう少し多くなるのですが、ここでは広告アドバタイズ(iBeacon)や GATT の最初のデバイス検出だけを考えてみましょう。

BLE 1M PHY では、1 Mbps となるので、47 bytes のデータを送ると概ね 400 μs 程度で送信できます。0.4 ms 以下ですね。実に短く一瞬のうちに終わります。

実に短時間で 47 byte のデータが飛び交っているので、3 チャンネルしかない広告チャンネルであっても、iBeacon デバイスが 100 台ぐらいあっても衝突はほとんど起こりません。これは、データ自体が 0.4 ms 程度の短時間で送受信されるためです。

つまり、接触確認アプリを持ったひとたちが 100 人位ぎゅうぎゅうに集まったとしても(コンサートとか満員電車とか)、BLE の広告チャンネルでの衝突はほとんど起こらない、ということです。

これ、現象として、たくさんの人たちが集まると携帯電話が繋がりにくいという現象とは異なることがわかります。携帯電話の場合は、基地局と常に通信することになるので、BLE の広告アドバタイズとは発見タイミングが異なるということです。

アドバタイズ間隔とデータ量

さて、iBeacon の発信や、GATT のデバイス発見のためのアドバタイズが、常に発信できる(常にセントラルで発見できる)状態になっているとは限りません。と言いますか、広告アドバタイズは、どのように発信されているのでしょうか?

実は、47 bytes のデータが常に発信されているわけではなく、一定間隔で発信されています。この間隔をアドバタイズ間隔(Advertising Interval)と言います。

  • 広告間隔(Advertising Interval)
    – 最小 20 ms、最大 10,240 ms
    – デフォルトは 100 ms 程度
  • アドバタイズデータのデータ量
    – 最大 47 バイト程度
    – 実質的に 0.4 ms 程度で送信可能
  • アドバタイズの揺らぎ advDelay
    – 0 〜 10 ms のランダムな遅延が追加される

送信している時間は 0.4 ms と短いのですが、それが常に送信されているわけではありません。広告間隔(Advertising Interval)によって、一定の間隔で送信されています。この他に誤差ではありますが、アドバタイズの揺らぎ(advDelay)として、0 〜 10 ms のランダムな遅延が追加されています。これはきっちりとした間隔で送信してしまうと、受信側で同期していしまってアドバタイズが受信できなくなってしまうからです。このあたりは BLE アドバタイズの仕様ですね。

つまりは、こんな風に iBeacon デバイスが発信しているときにうまくスマホアプリが受信していないといけないわけです。

このあたり有線通信ではなくて無線通信ってところがミソです。無線通信の場合は、発信側と受信側が常に同期しているわけではないというのと、発信側のバッテリーの問題があります。特に iBeacon 発信デバイスはバッテリーが少ないので、できるだけ発信回数を減らすことが求められます。
つまりは、電力消費の観点から、できるだけ広告間隔(Advertising Interval)を長くしたい、ということです。

スキャン間隔とスキャンウィンドウ

では、iBeacon や GATT のデバイス発見のために、スマホアプリ側はどのように受信しているのでしょうか?
これも常に受信状態にあるわけではなく、スキャン間隔とスキャンウィンドウという仕組みで受信しています。

  • スキャン間隔(Scan Interval)
    – 最小 2.5 ms、最大 10,240 m
    – デフォルトは 100 ms 程度
  • スキャンウィンドウ(Scan Window)
    – 最小 2.5 ms、最大 10,240 ms
    – スキャン間隔以下の値に設定する必要がある
    – デフォルトは 100 ms 程度

緑の部分がスキャンウィンドウ(Scan Window)で、ここで受信を行っている状態です。その Scan 状態も常に続いているわけではなくて、スキャン間隔(Scan Interval)が空いています。
つまり、iBeacon がうまく送信時間になっているときに、スマホアプリのほうがうまく Scan Window の時間に入っていないと受信できない、ということです。
実に絶妙なタイミングが必要なわけです。

ただし、さきほど書いたように送信時間自体は 0.4 ms と非常に短い状態です。
これに対してスマホの Scan Window は最小の設定でも 2 ms 程度はあるし、もっと長くすることも可能です。
バッテリーの問題として、データの発信には電力が必要ですが、データの受信にはあまり電力が必要ではありません。よって、発信は短く、受信は長くしておくのがセオリーです。実際 BLE の送受信はそういう仕組みになっています。

しかし、図のように、必ずしも BLE のアドバタイズのデータがスマホ側の Scan Window にあてはまるとは限りません。真ん中のデータはうまく受信していますが、左のデータは Scan Window の外側で発信されているために受信できていません。

BLE アドバタイズの取りこぼし or 遅延の原因

この Scan Window, Scan Interval と Advertising Interval の関係で、BLE アドバタイズの取りこぼしや発見遅延が発生します。つまりは、確率的に、最初の一発目で受信できることもあれば、なかなか受信できないという現象が発生するわけです。

概算する式的にはこんな感じになります。

発見遅延 = Advertising Interval / Scan Duty Cycle
Scan Duty Cycle = Scan Window / Scan Interval

例えば、Advertising Interval が 100 ms、Scan Interval が 100 ms、Scan Window が 50 ms の場合、Scan Duty Cycle は 0.5 となり、発見遅延は 200 ms となる。つまり、平均して 200 ms 後に発見されることになります。200 ms 後に発見されるならばまだ誤差として良い方です。

例えば、相互のバッテリー消費を少なくするために、次のような設定をしたとします。

  • Advertising Interval: 1000 ms
  • Scan Interval: 2000 ms
  • Scan Window: 100 ms

この場合、Scan Duty Cycle は 0.05 となり、発見遅延は 20,000 ms となる。つまり、平均して 20 秒後に発見されることになります。

これの意味は、20 秒後に必ず発見されるという意味ではありません。
iBeacon デバイスや BLE ワイヤレスイヤフォンのように、その場に居続ける端末の場合にはいいのですが、近接接触確認アプリのように相互に歩いていたり、席から離れたりするときには 20 秒後には居なくなっている可能性もあります。特にすれ違いの場合はいなくなっています。すれ違いなんって距離を含めれば 5 秒もないでしょう。
なので、発見遅延を考えると、20 秒の発見遅延を許す場合は、すれ違いの発見は実質できないということなります。正確に言えば、すれ違いを発見したり、発見しなかったり、という現象が発生します。

これも、必ずしも発見できないわけではなく、確率的に発見できない可能性があるということです。逆に言えば、接触データを調べたときに、接触データがないなかといって接触がなかったとは限らない、という状態です。いわゆる、信頼区間というものがあるのです。
また、この例でいえば、平均 20 秒程度の遅延が発生するわけですから、接触データの時刻に接触したという訳ではないということです。それよりも平均 20 秒前には既に接触している、という意味になります。

Advertising Interval と Scan Duty Cycle のバランス

というわけで、Advertising Interval と Scan Duty Cycle のバランスが重要になります。
店舗などの入店確認では 1 分程度の遅延が許されるが、接触確認アプリではどのくらいの遅延が許されるのかを考えないといけません。

  • 発見遅延が大きい場合(1,2分とか)、すれ違いの場合は検出できない?
  • 発見遅延が小さい場合(数百 ms)は、電力の消費がどのくらいなのか?
  • 発見遅延が小さい場合、数十台の端末が集まったときはどうなるのか?

ということをシミュレーションする必要がでてきます。

余談

ただし、このあたりは物理層としての電波の発信/受信の問題だけでなく、OS 側の BLE スタックの実装(特に セントラル側のスキャン動作)にも依存すると思われます。

よって

  • 電波の到達だけでなく、実働として OS 側の BLE スタックの動作も調査する必要がある
  • 電波は届いているが、発見遅延により検出されない
  • 電波は届いているが、ペリフェラルの端末が多いため、検出されない(プログラムの問題?)

という違いが出てくる。これを実機で確認する必要がでてきます。

このあたり、スマホ端末での BLE が不意に止まってしまう現象を次の話題にします。特に Android の機種によっては、連続した BLE 運用は考えていないらしく、途中で BLE が止まってしまいます。

カテゴリー: 開発, FolkBears | BLE 物理層の詳細とスキャン頻度 はコメントを受け付けていません

BLE 通信チェックツールのあれこれ

しばらく BLE の話を続けます。

COCOA や FolkBears での BLE 通信状態を調べるのには、それなりのツールが必要なのですが、まずは既存の Android/iOS アプリを探していきました…ってのを記録しておきます。
結論から言うと、どれも汎用的過ぎて、結果的に自前でツールを作ることころに至るのですが、初手として BLE 通信を調べたい場合には以下のようなアプリがあります。

リンク先は Android アプリのものですが、iOS 版もあります。

SimpleLink Connect

Simplelink Connect – Apps on Google Play https://play.google.com/store/apps/details?id=com.ti.connectivity.simplelinkconnect&hl=en-US&pli=1

Texas Instruments のテストツールなのですが、これが仕事上一番使いやすかったです。

BLE スキャンを開始すると、周辺の BLE デバイスをスキャンします。「スキャンします」という云い方をしますが、実際は、BLE ペリフェラルが発信しているアドバタイデータを傍受している、という感じです。ちょうどレーダー受信機みたいなものですね。なので、周辺にある BLE 端末がごっそり表示されます。

これ、周辺には結構な量の BLE デバイスがあることが分かります。大抵の端末は名前がついていないので「Name Unknown」となっています。ハッキリ言って、どれがどの発信をしているのか区別が付きません。接続しようとすると接続できないものも混じっているので(ひとつの端末が複数のアドバタイズを使っているものもあり、中華な製品だとデータ内容がめちゃくちゃなものもあります、これは別途ツールを使うと調査ができます)、なかなか目的ものを見つけるのが大変です。

ひとまず、スマホの Android 端末とか、ワイヤレスイヤフォンとか BLE 接続のスピーカーみたいなのも見つかります。
デバイス名がついていないと、どの端末かわからないというのと、同じデバイス名がついていると判別がつかないというのが難点ですが、サービス UUID さえわかっていれば見つけやすいですね。

ただし、アドバタイズがスキャンできなくなったものもリストに残り続けるので、ずっと開いていると動かなくなります。

BLE Scanner

LightBlue® — Bluetooth LE – Apps on Google Play https://play.google.com/store/apps/details?id=com.macdom.ble.blescanner&hl=en-US

最初の頃によく使っていたツールです。

電波強度が強い順、つまり近い順から表示されるので目的の BLE デバイスを見つけやすいです。ひとまず、手元の近くにあるものが上に来ています。下のほうにあるのは、遠くのものなのでひょっとすると外で歩いている人の BLE ワイヤレスイヤフォンとか、iPhone の端末を拾っている可能性が高いです。

デバイスの RAW DATA の部分をタップすると、アドバタイズされているデータが表示されます。

一見すると何がよいのか分かりませんが(私も BLE を最初に扱ったときにはわかりませんでした)この表示が意外と重宝します。BLE データの 32 byte(実際には、先頭のフラグないときがありますが)が 16 進数で表示されているので、データそのものを解読することができます。

ただ、iOS の BLE ライブラリは受信データの制限がきついので BLE Scanner はどうやっているんだろう?という不思議なところがあります。タイプが 0xFF なので manufacturer data を受信しているのだと思いますが…
ただし、無理に iOS で動かさずとも、Android や Windows あるいは m5stack などの端末を使えば、これらのデータは自由に解析できるようになります。

LightBlue

LightBlue® — Bluetooth LE – Apps on Google Play https://play.google.com/store/apps/details?id=com.punchthrough.lightblueexplorer&hl=en-US

LightBlue も BLE デバイスをスキャンしてくれますが、先の2つのアプリとは違い、接続リストが控え目に表示されます。実は、これコネクションできるデバイスだけしか表示しないので、iBeacon のようにアドバタイズのみの場合は発見できません。

ただし、「virtual Devices」のところが便利で、BLE ペリフェラルをシミュレートしてくれます。

いわゆる、BLE セントラルを作っているときに、ペリフェラルの端末のほうがないとか、複数のペリフェラルを作りたいときに便利です。まあ、これも BLE デバイスのほうがないというパターンはあまりないので使いどころが難しいのですが、BLE をサーチするサーバー側を作って、温度やバッテリーなどのセンサーの類を探す、ってところに便利です。

ただし、いまだと、BLE ペリフェラル側を m5stack で作ってしまったほうが楽です。

BeaconSET Plus

BeaconSET Plus – Apps on Google Play https://play.google.com/store/apps/details?id=com.minew.beaconplus&hl=en-US

確か、iBeacon を検出するためのツールなのですが、いわゆる iBeacon 以外の Beacon(Eddystone など) も検出できるはずです。

もともと BeaconPlus | 株式会社テクサー https://techsor.co.jp/product/02113 という製品の検出アプリです。iBeacon のほうはおまけなんだと思うんですが、これを使って iBeacon を検出することができます。

ただし、iOS の場合は、iBeacon の UUID をあらかじめ入れておかないといけないので使い勝手は悪いです。これは iOS の仕様なので仕方がないですね。使うときは Android 版を使うと良いです。

Beacon Scan

Beacon Scan App – App Store https://is1-ssl.mzstatic.com/image/thumb/Purple7/v4/76/bc/c7/76bcc76c-aaa3-6a1a-1e52-8bcbb2dba1f7/pr_source.png/230x511bb.webp

これは iOS アプリのみですが、iBeacon をスキャンするアプリです。これも、iOS の仕様で UUID を設定しておかないといけないのですが、ほどよく検出が可能です。
たしか、同じ UUID を登録しておいて、複数の iBeacon を検出できます。つまり、同じ UUID を使っていてバージョンを変えることによって、複数の iBeacon を検出する、というテストができます。

Android の場合は、iBeacon の UUID は指定しなくても受信は可能なのですが、iOS の場合は 10 個ぐらいだったか数個しか登録できないという制限があります。

そんな訳で、公開されているアプリを使うと便利に BLE デバイスのテストができるんですが、目的の BLE デバイスを見つけるのが結構大変で手間がかかります。SimpleLink Connect とかで見るとわかりますが、周辺には大量に、身元不明の BLE デバイスがあるわけです。
なので、テスト用や負荷試験のために自前で BLE デバイスや検出器を作っていきました。ってのが次の話題です。

カテゴリー: 開発, FolkBears | BLE 通信チェックツールのあれこれ はコメントを受け付けていません

近接接触確認アプリ FolkBears の BLE 通信構造を探る

前置きが長くなりそうですが、ざっとまとめておきます。もともとの発端は新型コロナウィルスの感染対策用アプリのである「COCOA」の前身の頃がスタートになります。いわゆる、近接接触確認アプリというジャンルです。既に COCOA だけでなく近接接触確認アプリ自体は各国で終了となっていますが、次期のため、といいますか、同様の技術を継承するために FolkBears というアプリを開発し続けて今に至っています。

元ネタは まもりあいJapan https://github.com/mamori-i-japan の mamori-i-japan-android と mamori-i-japan-ios をベースにして、北見工業大学 https://www.kitami-it.ac.jp/ が独自開発をして、FolkBears https://github.com/FolkBearsGroup/folkbears-android を開発するに至っています。現在のところ android 版のみコード公開してますが、ios 版もあり、公開のためにドキュメント等を整え中です。

開発経緯自体は、別のことろでまとめるとして、ここでは FolkBears の本質なところの BLE 相互通信の部分の解説をします。

COCOA の BLE 通信構造

COCOA の BLE 通信構造は、Google/Apple の Exposure Notification API (ENA) https://developer.apple.com/documentation/exposurenotification で提供される API を利用しています。ENA 自体は、国が開発することが前提となっているライブラリなので、現在一般的な開発を試みることはできません。しかし、ENA の BLE 通信構造自体は公開されているので、その構造を真似ることは可能です。

端的に言えば、ENA は Bluetooth Low Energy (BLE) を使って、コネクション無しの広告型(アドバタイジング)で ID を配布します。受信する端末は ID を保管しておき、サーバーで ID 同士を比較するという形になっています。問題は、このアドバタイジングの部分です。

  • ENA のアドバタイズはコネクションレスである
  • BLE Service UUID は 16 bit(0xFD6F)である

という仕様となっています。

FolkBears も同様の仕様にしたいところですが、これを真似ることはできません。BLE Service UUID は一般に使う場合には 128 bit を使うことが推奨されており、16 bit UUID は Bluetooth SIG に登録しなければいけません。お金がかかります。0xFD6F は Apple の UUID であり、ENA で使うようにしてありますが、これが使えません(OS レベルでガードが掛かっています)。また、ENA で使っているコネクションレスの接続方法は、iOS では使えません。ENA が使えるのは OS レベルで特別な仕様として使っているもので、iOS の API としては用意されていないわけです。

そのあたりの諸々の事情もあり、元ネタである「まもりあい Japan」バージョンは BLE によるコネクション版が使われています。FolkBears も同様にコネクション版を使うことができます。

ただし、、これも諸々の実験の事情もあり、コネクション版も不安定なところがあるため、ちょっとトリッキーな形ですが、

  • コネクションレス版の通信
  • iBeacon 形式を利用したコネクションレスの通信

の2種類を用意することができています。

COCOA / ENS の通信状況が検証されていない

このあたり、有耶無耶になっていると思うのですが、COCOA あるいは ENA の実機における通信状況はほとんど検証されていません。研究段階として、ドイツのアプリで距離を測ったデータなどがあるのですが、実際に 10 人集まった時のデータや、連続運用したときのデータや、人がすれ違ったときのデータなどの詳細なデータは公開されていません。すくなくとも私は目にしたことがありません。

というのも、ENA の仕様自体で、開発が国単位に制限されているのと状況が緊迫していた状態から、開発ログなどの研究データを取得する手段が完全にふさがれているためです。いちおう、デバッグモードの情報を取れるようになったおり、試験サーバーを用意して突き合わせをすることは可能なのですが、それをやっているところは見たことがありません。

そのあたり、緊急事態であったので仕方がないところではありますが、せっかくの技術がブラックボックス化してしまっているのは残念なところです。

できるだけ ENA に似せた状態で、研究データが取れる

FolkBears は、ENA に似せた状態で、かつ研究データが取れるように設計し直しています。本格的な運用(ENA が目指した個人の動向の隠蔽など)という点では現状では達していませんが、まずは、スマホ実機を使ったときの動作や通信状態を確認できるようにしています。

相互した結果は別途 aware サーバーに送ることで、どのような通信が行われたかを確認できるようになっています。これをもとに学部生や大学院生が研究ができることを目指しています。

通信構造とシーケンス

COCOA の場合

COCOA の場合、ENA の仕様に従って、以下のような通信構造とシーケンスになっています。

Exposure_Notification_-_Bluetooth_Specification_v1.2.2 より

BLE のデータ自体は 32 byte しか取れないので、Service UUID などの情報を除くと、ID を送信するため(Rolling Proximity Identifier: RPI)に 16 byte、付加情報(Associated Encrypted Metadata: AEM)に 4 byte しか使えません。
これは、先に書いた通り、お金を払って 16 bit の Service UUID を取得すれば使える問題なのですが、FolkBears ではそういうわけにはいきません。さらに言えば、お金を払って 16 bit UUID を取得したとしても、iOS ではコネクションレスのアドバイズができないので、ENA と同じ動作にはできない、という問題が残ります。

余談ですが、iOS を外してしまって、Android だけで実装するとか、M5Stack のようなマイコンで実装する場合には、16 bit UUID を取得して ENA と同じ構造にすることは可能です。これは実際、実験でできています。

FolkBears の場合(コネクション版)

もともと、まもりあい Japan ではコネクション版の通信を使ってあります。現コードでは、データの送受信状態がうまくいかなかったのと、コードの再構成のためにかなり書き直してはありますが、基本的な通信構造は同じです。

BLE のコネクション版では、Service UUID は 128 bit を使うことができます。これはフリーで使えます。
コネクション版のシーケンスは、次のような形です。

この操作を相互に行うことになります。一見すると、このコネクション版ではうまく動作するような気もしますが、実機で連続運用してみるといくつか問題が発生します。(実は、まもりあい Japan モードの場合は、データ送信(ID) の部分を Write/Read の両方でやっているので、相互にデータ送信が行うようになっていました。が、この部分が不安定だったので、いったん片方だけに変更しています)

  • コネクションを頻繁に行うため、BLE 接続で不安定になることが多い
  • Android の機種によって、BLE デバイスが止まることがある
  • Android のバージョンによってバックグランド動作の違いがある
  • Android の機種によって、BLE のスキャン頻度が異なる
  • Android と iOS の接続頻度が異なる
  • iOS からの接続頻度が多くて、Android 側がパンクすることが多い
  • 5,6 台集めたときに、接続されない端末た出ることが多い

接続頻度に関しては、再接続までの感覚を1分間隔にするなどの工夫をしていますが、まだ根本的な解決に至っていません。特に Android の機種(特に中華製)では、BLE が不安定になることが多く、BLE ドライバの ON/OFF を繰り返さないと復帰でない現象がでています。

2020 年頃の秋ごろに COCOA の Android 版で受信がされない現象が頻発していましたが、これが原因かもしれません。そのあたりも検証したいところです。

FolkBears のコードとしては、folkbears-android の以下に実装されています。

  • app/service/GattAdvertise.kt
  • app/service/GattServer.kt
  • app/service/GattClient.kt
  • app/service/GattTraceService.kt

FolkBears の場合(コネクションレス版)

コネクションの場合、コネクション確立と ID のデータ送信の2手順となるため、多くの端末が集結したときに問題がおきそうです。実際、5,6 台の Android を集めると、通信量が多くなって(単純に接続頻度が5倍になってしまいます)しまうので、満員電車やコンサート会場のように 100 台位の接近した状態になるとパンクしそうです。

ただし、実際のところ COCOA のときに試してみたのですが、受信自体は 20 台程度が限界のようです。これは当時、実際にツールを使って AEON で確認してみました。

そうなると、ENA のようのコネクションしない状態で、つまり、アドバイズする BLE データ自体に ID を乗せて配信するのがよさそうです。実際、Google/Apple の ENA もそうなっています。
ですが、先に書いた通り iOS ではコネクションレスのアドバイズができないので、ENA と同じ動作にはできない、という問題が残ります。実験機としては Android 限定でもよいのですが、実運用を考えるとそうもいきません。

そこで、iBeacon 形式を利用したコネクションレスのアドバタイズを使うことにしました。iBeacon 形式は Apple が提唱している BLE のアドバタイズ形式で、iOS でもサポートされています。iBeacon 形式では、Service UUID の代わりに Proximity UUID (128 bit)、Major (16 bit)、Minor (16 bit) を使ってデータを送信します。これを ID としてうことにしています。

これだと、コネクション版と違い台数が多くなっても大丈夫そうだし、通信形式が ENA に似ているので検証としてもよさそうです。

ただし、これも難点があって、ENA の ID/RPI は 16 byte ですが、iBeacon 形式では Major (16 bit) + Minor (16 bit) で合計 4 byte となってしまいかなり範囲が狭いです。衝突の問題がもありますが、ひとまず研究用として、4 byte の ID を使うことにしています。

これで良さそうなコネクションレスの iBeacon 版ですが、これも実装上いくつかの問題があります。

  • iOS 側のスキャンでは、移動状態しか判別できない
  • iOS ではバックグラウンドで iBeacon の発信ができない
  • iOS と Android で iBeacon 発信の頻度が大きく異なる

特に iOS 側のスキャンでは、相手が移動した(距離が変わった)ときにしかイベントが発生しないらしく、いまのところ常に取得できているように見えますが、実際にどうなるかは不明です。大抵の iBeacon 受信アプリでは、店内に入ったとときや、美術館や博物館で iBeacon 発信機の近くに寄ったときにイベントが発生するので、常に近接している状態ではイベントが発生しないという仕様になっています。

まあ、それであっても、ENA のようにコネクションレスで ID を配信する形にはなるので、比較対象の研究用としてはよいかと思います。

コード的には

  • app/service/BeaconScan.kt
  • app/service/BeaconTraceService.kt
  • app/service/BeaconTransmitter.kt

に実装されています。

長くなったので

長くなったので続きは、別途書きます。
今度はもうちょっと、BLE 通信部分をコードレベルで、実際の動きの詳細を解説します。

余談ですが

余談ですが、FolkBears のリファクタリングには Copilot を使っています。元コードが絡み合ってしまっていて不具合解消が上手くいかない状態が続いていたのですが、ここ1年位の AI コーディングの進歩がすさまじく、結構綺麗にリファクタリングができています。このあたりのノウハウも公開していく予定です。

参考資料

カテゴリー: 開発, FolkBears | 近接接触確認アプリ FolkBears の BLE 通信構造を探る はコメントを受け付けていません

LibreOffice Calc で マクロを使う標準的な方法をおさらい

年末年始でいきおいで作ってしまった ExcelLikeUno というライブラリは「LibreOffice Calc のマクロを Python で書こうとすると UNO API を直接扱わないと大変なので、Excel の VBA マクロのように簡単に書けるようにしよう」というのが趣旨です。
が、そもそも、 LibreOffice Calc というか LibreOffice のマクロを何で書けばいいのか? ってのを自分のためのおさらいしておきます。

つまりは、勢いで作ったものだから、LibreOffice の標準機能とダブって作っても仕方ない、ということに今更ながら気づいた…というところです。

LibreOffice のマクロドキュメント

[The LibreOffice Help Window](https://help.libreoffice.org/latest/en-US/text/shared/05/new_help.html) の下の「Macros and Scripting」にあります。

なぜかうちの環境ではマクロエディタでコード補完が効かないので(何かバグっている?)、じゃあ Python で作るかと思い立ったのですが、実際はコード補完が効くようです。このあたりはよくわかりません。

LibreOffice Basic

いわゆる文法が Basic なマクロです。
「ツール」→「マクロ」→「マクロの記録」で保存されるのは、この LibreOffice Basic です。
UI から操作できて記録できるので、これが一番参考になります。Excel VBA と似た感じで作れます。

コード補完は [Basic IDE](https://help.libreoffice.org/25.8/ja/text/shared/optionen/BasicIDE.html) によって提供されている…筈なのですが、うちの Windows 環境では動きません。ちなみに Ubuntu 版では「Basic IDE」のメニューがありません。

ちなみに、A1 セルを選択して、”masuda” と入力すると次のようなコードが出力されます。

REM  *****  BASIC  *****

sub Main
	rem ----------------------------------------------------------------------
	rem define variables
	dim document   as object
	dim dispatcher as object
	rem ----------------------------------------------------------------------
	rem get access to the document
	document   = ThisComponent.CurrentController.Frame
	dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")
	
	rem ----------------------------------------------------------------------
	dim args1(0) as new com.sun.star.beans.PropertyValue
	args1(0).Name = "ToPoint"
	args1(0).Value = "$A$1"
	
	dispatcher.executeDispatch(document, ".uno:GoToCell", "", 0, args1())
	
	rem ----------------------------------------------------------------------
	dim args2(0) as new com.sun.star.beans.PropertyValue
	args2(0).Name = "StringName"
	args2(0).Value = "masuda"
	
	dispatcher.executeDispatch(document, ".uno:EnterString", "", 0, args2())
	
	rem ----------------------------------------------------------------------
	dispatcher.executeDispatch(document, ".uno:JumpToNextCell", "", 0, Array())

end sub

これ、今だから分かるのですが、初心者にとっては相当きついです。というか、無理です。
ただし、Excel VBA と同じように、とりあえず記録をしておいて中身は何も見ないで動かしている、人にはいいかもしれません。完全にピンポイントの動かし方になります。

ちなみに Excel でやるとこんなコードになります。

Sub Macro1()
'
' Macro1 Macro
'

'
    Range("A1").Select
    ActiveCell.FormulaR1C1 = "masuda"
    Range("A2").Select
End Sub

これだと参考にして、何かに拡張できそうですよね。Excel VBA が広まったのは、この手軽さと入門のしやすさがあったので、ここぐらいまでは敷居を下げて欲しいところです。

VBA 互換モード

LibreOffice Basic の中に「VBA 互換モード」というのがあります。
[Support for VBA Macros](https://help.libreoffice.org/latest/en-US/text/sbasic/shared/vbasupport.html?DbPAR=BASIC)

どこまで互換性があるかというと、先の Excel VBA のコードをそのまま貼り付けて動かせる、というレベルです。

option vbasupport 1

Sub Macro1()
'
' Macro1 Macro
'
    Range("A1").Select
    ActiveCell.FormulaR1C1 = "masuda"
    Range("A2").Select
End Sub

もう、Excel VBA から移植する場合にはこれで十分ですね、ってレベルです。
先頭に `option vbasupport 1` を入れてしまえば大抵のものが動きます。

ただし、最大の難点は BASIC で書かないといけないことと、Excel VBA の文法を引きずってしまうところです。何か修正するときに BASIC は、まあいいとして、LibreOffice Calc なのに、何故 Excel VBA で書かなくてはいけないのか? という違和感が将来的に残ります。

ScriptForge ライブラリを使う

実は ScriptForge って OSS のライブラリかと思っていたのですが、LibreOffice に標準で付属しているライブラリなんですね。
[ScriptForge Libraries](https://help.libreoffice.org/latest/en-US/text/sbasic/shared/03/lib_ScriptForge.html?DbPAR=BASIC)

先の LibreOffice Basic で書いていたややこしいコードも ScriptForge ライブラリを使うと、かなりシンプルに書けます。

実際のところは

  • LibreOffice Basic で標準的なセルを扱う操作
  • セルの移動や、コピー&ペーストなどは、ScriptForge ライブラリを使う

という使い分けになります。

セル A1 に “masuda” と入力するだけならば、次のコード

sub Main
    dim oSheet as object
    oSheet = ThisComponent.Sheets.getByIndex(0)
    dim oCell as object
    oCell = oSheet.getCellByPosition(0, 0) ' A1 セル
    oCell.String = "masuda"
end sub

を、ScriptForge ライブラリを使うと次のように書けます。

sub main
    GlobalScope.BasicLibraries.loadLibrary("ScriptForge")

    Dim oDoc As Object
    Set oDoc = CreateScriptService("Document", ThisComponent)
	oDoc.SetValue("A1","masuda")
end sub

ScriptForge ライブラリの場合、どちらかというと UI 操作を抽象化しているので、セルの値を直接操作する場合は、LibreOffice Basic の方がシンプルに書けます。

これも慣れるといいんでしょうが、新たに LibreOffice Basic と ScriptForge ライブラリの両方を覚えないといけないのが難点です。
ただし、LibreOffice Calc だけじゃなくて Writer などの機能の網羅しているので、LibreOffice 全体のマクロを作る場合には便利かもしれません。

Python でも ScriptForge ライブラリは使える

ExcelLikeUno ライブラリを作ってから、最近気づいたのですが、Python からでも ScriptForge ライブラリは使えます。なので、基本的に Python でマクロでも UI 操作はScriptForge ライブラリを使うのが標準なのでしょう。

from scriptforge import CreateScriptService
doc = CreateScriptService("Calc")

def macro1():
    doc.SetValue("A1", "masuda")

Calc の内部から動かすマクロでも XSCRIPTCONTEXT 経由と ScriptForge ライブラリを組み合わせて使うとかなりのところまでいけます。

で、ExcelLikeUno ライブラリはどうするか?

LibreOffice Calc のマクロを作ろうとしたときに難点が

  • LibreOffice Basic でコード補完が使えない
  • UNO API を直接使うのが大変
  • Excel VBA 互換モードは、将来的にも Excel VBA の文法を引きずることになる
  • ScriptForge ライブラリは便利だが、コード補完が使えない
  • UNO API の構造と ScriptForge ライブラリの構造を覚えないといけない

というところだったので、私としては “コード補完が使えない” ところが一番のストレスです。まあ、LibreOffice Basic の IDE はうちの環境だけかもしれないのですが、少なくとも Ubuntu 版では Basic IDE のメニューがないので、コード補完は使えません。

あと、今更 Basic でやるのもちょっと不便です。Web API に連携しようとか、外部データベースにアクセスしようとかした場合には、Python のほうが便利です。というか LibreOffice Basic で外部 Web API を呼び出せるのかわかりません。

そうなると、

  • Python で書く
  • LibreOffice Calc を操作するときにコード補完が有効になる
  • Excel VBA っぽく、直感的に簡単な操作がよい(マニュアルを熟読とかしたくない)

というパターンが自分には残るわけで、ScriptForge ライブラリでちょっと使いづらい部分を ExcelLikeUno ライブラリに移植する、ってのがよさそうです。完全移植をやろうとすると大変なので、ダイアログの表示とかは ScriptForge ライブラリに任せた方がよさそう。

参考先

カテゴリー: 開発, LibreOffice | LibreOffice Calc で マクロを使う標準的な方法をおさらい はコメントを受け付けていません

Linux 版の LibreOffice Calc で ExcelLikeUno を使う

結論から言うと、ちょっとトリッキーな設定が必要ですが、できます。

Linux 版といっても色々あるので、詳しい検証は後からになるのですが、ひとまず自分の環境である Ubuntus 24.04 の LibreOffice のダウンロード版(snap版じゃないほう)で通りました。

LibreOffice のインストール

sudo apt install libreoffice
sudo apt install python3-uno

作業環境を Windows/Linux/macOS のどれに寄せるのかは、たびたび議論になるところでしょうが、要は “自分のところで動けばいい” ってのが優先させます。あと、仕事で使うので “客先の環境で動かないといけない” ってのがありますね。

Ubuntu 24.04 の制限

それ以前の環境と Python の使い方が変わっているので、注意が必要です。

  • ダウンロード版の LibreOffice には Python が含まれない。このために OS 側の Python を使う
  • OS 側の Python はセキュリティ上 pip ができない。

という訳で一気に詰み…というわけでもなく、ローカルスクリプトのほうに pip できたり、LibreOffice が使う Python を指定したりできればいいのですが…後者の LibreOffice が使う Python のほうはそういう設定がありません(たぶん)。

ちなみに、うちの Ubuntu はこんな劣悪な環境で動いています。Hyper-V でもいいのだけど、ちょっと mini pc が余ってしまったので、練習用に。

LibreOffice 関係のパス

LibreOffice の python マクロのパス(古いのだと LibreOffice のように大文字になっていますが、最新版は libreoffice のように小文字です)

~/.config/libreoffice/4/user/Scripts/python

Python3 の site-packages のパス

/usr/lib/python3/dist-packages

要は、OS で使っている python3 の site-packages/dist-packages に pip のライブラリを突っ込めばいいのです。どうせ、自分だけの環境(あるいは客先だけの環境)なのですから、セキュリティ関連は自前もちということで。

なので、

  • python3 -m venv .venv で 仮想開発環境を作成する。
  • .venv 環境内で pip install exellikeuno する
  • .venv/lib/python3.12/site-packages 内に excellikeuno がインストールされる
  • .venv/lib/python3.12/site-packages/exellikeuno を /usr/lib/python3/dist-packages 内に sudo でシンボリックリンク(ln -s)する

という方式です。フォルダ名を ~/libre にしていますが、これは適当に作って大丈夫です。

mkdir libre
cd libre
python -m venv .venv
.venv/bin/pip install exellikeuno
ls .venv/lib/python3.12/site-packages
sudo ~/libre/.venv/lib/python3.12/site-packages/excellikeuno /usr/lib/python3/dist-packages/

のような感じで動かします。最後だけ sudo なので注意してください。

リモート vscode で python マクロを編集する

Ubuntu 上で vscode で編集すればよいのですが、私の場合はメイン環境が Windows なので Windows から Ubuntu へ vscode でリモート接続します。

モジュールとかのコード補完が効くように .vscode/settings.json も設定しておきます。

{
    "python.analysis.autoImportCompletions": true,
    "python.analysis.extraPaths": [
        "~/.config/libreoffice/4/user/Scripts/python/excellikeuno",
    ]
}

ExcelLikeUno 関係のコード補完は pip でインストールしてあるので、自動で補完されます。

Calc のマクロで動かす場合はこんな感じ。

マクロが Basic なのか Python なのかは、自動判別してくれます(これ、Windows 版が自動判別じゃないのがおかしいw)

LibreOffice Calc の外部から起動したい場合は、サーバーを起動します。

soffice --accept="socket,host=localhost,port=2002;urp;" --norestore --nologo

OS 付属の Python3 を動かせば ok です。

python3 calc_sample_shogiban.py

まあ、UI を使う場合は Ubuntu の Python の制限もあり、ややこしいことになっていますが、Docker や WSL のヘッドレス(GUI がないモード)の場合はもうちょっとやりやすいはずです。LibreOffice を使って PDF 化したり整形するようなサーバー処理を作りたい場合は、Docker のほうが便利だと思うので、これを後ほど試します。

カテゴリー: 開発, LibreOffice | Linux 版の LibreOffice Calc で ExcelLikeUno を使う はコメントを受け付けていません

LibreOffice の Python macro で pip を使う

長年…といっても、半月ほどですが、懸念であった LibreOffice の Python マクロで pip を使える方法がわかりました。

moonmile/ExcelLikeUno: LibreOffice の Python マクロを Excel VBA ライクに操作 https://github.com/moonmile/ExcelLikeUno

というか、普通に LibreOffice Python が使っているところに pip できます。

& 'C:\Program Files\LibreOffice\program\python' -m pip install excellikeuno

このように LibreOffice が使っている Python を -m スイッチを付けてインストールすると以下のフォルダに配置されます。

C:\Users\masuda\AppData\Roaming\Python\Python311\site-packages\

共有の Program Files 配下には入れられないので、代わりにユーザーの AppData の中に入ります。現在の LibreOffice Python は 3.11 を使っているので、Python311 フォルダーになります。将来的にバージョンが変わると、ここも変わる可能性は大です。

これで、Calc のマクロからも excellikeuno パッケージが参照できるようになるので、以下のように書けます。

"""LibreOffice Calc sample macro."""
from re import X
from typing import Any, Tuple
from excellikeuno import connect_calc_script
from excellikeuno.table.sheet import Sheet 

def hello_to_cell():
    ( _, _, sheet ) = connect_calc_script(XSCRIPTCONTEXT)
    sheet.cell(0, 0).text = "Hello Excel Like for Python!"
    sheet.cell(0, 1).text = "こんにちは、Excel Like for Python!"
    sheet.cell(0,0).column_width = 10000  # 幅を設定

    cell = sheet.cell(0,1)
    cell.CellBackColor = 0x006400  # 濃い緑に設定
    cell.CharColor = 0xFFFFFF  # 文字色を白に設定

g_exportedScripts = (
    hello_to_cell,
)

XSCRIPTCONTEXT の接続部分を隠蔽化させるために、connect_calc_script 関数を使って sheet を取得します。このあたりは、もっと簡素化して Excel VBA のように ActiveWorksheet, ActiveWorkbook として取得できるようにする予定です。

Python マクロ自体は、以下に保存されているので vscode で編集することができます。

C:\Users\{ユーザー名}\AppData\Roaming\LibreOffice\4\user\Scripts\python\

ただし、このままだと vscode のコード補完が効かない、パッケージのコード補完が効かないので Scripts/python/.vscode/settings.json を作成します。

{
    "python.analysis.autoImportCompletions": true,
    "python.analysis.extraPaths": [
        "C:/Users/masuda/AppData/Roaming/Python/Python311/site-packages"
    ]
}

“python.analysis.extraPaths” に、先の site-packages フォルダーを設定しておきます。

そうすると、ExcelLikeUno パッケージで定義してある Sheet クラスなどで、コード補完が働くようになります。

これだと、uno api 単体よりははるかにコーディングしやすいし、VBA オプションを付けたかんじで Excel VBA 風にコーディングができるはずです。

あとは、Excel VBA と uno api の対応表を作りながら、ちまちまとクラス設計をしていけば良いはず。uno api のほうが Java 的に綺麗に整理されはいるのだけれど、Excel VBA の構造に慣れていると、いまいち発想が届かないのです。まあ、いったん、Excel VBA に直してから、あらためて UNO api 風に戻していけばよいかなと。

ところで LibreOffice Python でも自由に pip ができる(多分、仮想環境では無理だろうけど)ことがわかったので、通常の pip パッケージも Calc 内部から使えるはずです。

  • OpenAI 関係の API を呼び出し
  • PyNum 関係で数式を使ってグラフを作成
  • Python マクロ内から Web API 呼び出し

あたりが可能です。このあたりは、別途ためしてみる予定。

カテゴリー: 開発, LibreOffice, python | LibreOffice の Python macro で pip を使う はコメントを受け付けていません

LibreOffice Calc で使う Excel Like UNO を仮リリース

正月にちまちま整備していた Excel Like Uno が、まあ動くようになったので仮リリースしておきます。

https://github.com/moonmile/ExcelLikeUno

moonmile/ExcelLikeUno: LibreOffice の Python マクロを Excel VBA ライクに操作 github.com/moonmile/Exc…一旦、仮リリース。

Tomoaki Masuda (@moonmile.bsky.social) 2026-01-05T03:04:00.398Z

12月に LibreOffice Calce の Python マクロを使って、諸々を試していたのですが、現状の uno api だけでは限界がありそうなので、更にラップをするライブラリを作って試しています。

目的としては、

  • UNO API の複雑さを隠蔽し、Excel/VBA に近いメソッド・プロパティ名でする
  • 型定義を充実させ、IDE 補完と静的解析をサポート

なところで、従来の Excel VBA に似せています。

実は LibreOffice Calc でも Option VBASupport 1 のオプションを付けると VBA 互換モードで動かせます。かなり Excel VBA に近いところまでエミュレートするので Excel VBA から移行する場合はこっちのほうが楽かもしれません。
ただ、いまさら VBA でやりたくないし、どうせならば Python か何か別の言語でやりたいところです。

サンプル calc_sample_cell.py

from excellikeuno import connect_calc
from excellikeuno.typing.calc import CellHoriJustify, CellVertJustify

(desktop, doc, sheet) = connect_calc() 
cell = sheet.cell(0, 0)  # A1 セルを取得
cell.text = "Hello, World!"  # 値を設定
sheet.range("A1:C1").merge(True)  # A1:C1 を結合

cell.font_size = 16
cell.font_name = "Arial"
cell.font_color = 0xFF0000  # フォント色を赤に

cell.row_height = 2000  # 行の高さを設定 20 mm
cell.HoriJustify = CellHoriJustify.CENTER
cell.VertJustify = CellVertJustify.CENTER


sheet.cell(0,1).text = "id"
sheet.cell(1,1).text = "name"
sheet.cell(2,1).text = "address"
sheet.range("A2:C2").CellBackColor = 0xFFBF00  # A2:C2 の背景色を設定

data = [
    [1, "masuda", "tokyo"],
    [2, "suzuki", "osaka"],
    [3, "takahashi", "nagoya"],
]
sheet.range("A3:C5").value = data  # 範囲にデータを一括設定

こんな感じで、最初に connect_calc() で接続してから、

  • sheet.cell( column, row ) でセルを取得
  • cell.font_size などのプロパティを使う

といいうことができます。テキストや値は、getText() や setText() を使うのではなく、text プロパティを使います。実際には、数値は value プロパティ、テキストが text プロパティにしてあります。

この Python コードを実行すると、LibreOffice Calc だと、こんな表示になります。
まあ、一般的な表ぐらいはできそうな状態です。

サンプル calc_sample_shogiban.py

罫線のサンプルを見てみましょう。

# 将棋盤を作る
from excellikeuno import connect_calc
from excellikeuno.typing.calc import CellHoriJustify, CellVertJustify
from excellikeuno.typing.structs import BorderLine

(desktop, doc, sheet) = connect_calc()
# sheet.name = "将棋盤"
ban = sheet.range("A1:I9");
ban.CellBackColor = 0xFFFACD  # 背景色を薄い黄色に設定
ban.row_height = 1000  # 行の高さを設定 20 mm
ban.column_width = 1000  # 列の幅を設定 20 mm
# 罫線を設定
for cell in [c for row in ban.cells for c in row]:
    borderline = BorderLine()
    borderline.Color = 0x000000
    borderline.OuterLineWidth = 50
    borderline.InnerLineWidth = 0
    borderline.LineDistance = 0

    cell.TopBorder = borderline
    cell.BottomBorder = borderline
    cell.LeftBorder = borderline
    cell.RightBorder = borderline
    # センタリング
    cell.HoriJustify = CellHoriJustify.CENTER
    cell.VertJustify = CellVertJustify.CENTER
    # フォントサイズを大きく
    cell.font_size = 16.0
    cell.CharColor = 0x000000  # 黒色に設定
    

# 駒を配置
pieces = [
    ["香", "桂", "銀", "金", "王", "金", "銀", "桂", "香"],
    ["", "飛", "", "", "", "", "", "角", ""],
    ["歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩"],
    ["", "", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", "", ""],
    ["歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩", "歩"],
    ["", "角", "", "", "", "", "", "飛", ""],
    ["香", "桂", "銀", "金", "王", "金", "銀", "桂", "香"],
]
ban.value = pieces  # 一括で駒を配置
# 相手の駒を反転表示
for r in range(9):
    for c in range(9):
        cell = ban.cell(c, r)
        if pieces[r][c] != "" and r < 3:
            cell.CharRotation = 180  # 180度回転

セルの大きさを変えて(いわゆる将棋盤にします)、駒をセルに書いていきます。
罫線をつけるところと、文字が180度回転させることができます。

まあ、これを Excel でやるかどうかは別ですが、Excel 方眼紙ぐらいならば作れそうです。

サンプル calc_sample_tsurukame.py

これは図形を配置する例です。

# 鶴亀オセロのサンプル
from excellikeuno import connect_calc
from excellikeuno.typing.structs import BorderLine

(desktop, doc, sheet) = connect_calc()
# sheet.name = "鶴亀オセロ"
ban = sheet.range("A1:H8");
ban.CellBackColor = 0x006400  # 背景色を濃い緑色に
ban.row_height = 1000  # 行の高さを設定 10 mm
ban.column_width = 1000  # 列の幅を設定 10 mm
# 罫線を設定
for cell in [c for row in ban.cells for c in row]:
    borderline = BorderLine()
    borderline.Color = 0x000000
    borderline.OuterLineWidth = 50
    borderline.InnerLineWidth = 0
    borderline.LineDistance = 0
    cell.TopBorder = cell.BottomBorder = cell.LeftBorder = cell.RightBorder = borderline

# オセロの駒を Shape で配置
pieces = [
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "黒", "白", "", "", ""],
    ["", "", "", "白", "黒", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
    ["", "", "", "", "", "", "", ""],
]

for r in range(8):
    for c in range(8):
        cell = ban.cell(c, r)
        piece = pieces[r][c]
        if piece == "":
            continue
        sheet.shapes.add_ellipse_shape(
            x=cell.position.X + 200,
            y=cell.position.Y + 200,
            width=600,
            height=600,
            fill_color=0x000000 if piece == "黒" else 0xFFFFFF,
            line_color=0x000000,
        )

セルに罫線と背景色をつけて、オセロの駒は EllipseShape(楕円)を使います。

サンプル calc_sample_mahjong.py

フォント指定と TextShape を使った例です。

# 麻雀牌を並べる
from excellikeuno import connect_calc
from excellikeuno.typing.calc import LineStyle

(desktop, doc, sheet) = connect_calc()
# sheet.name = "麻雀牌"

hai = ['1','1','1', '2','2','2', '3','3','3', '4','4','4', 'a',' ','a']

# 13個の TextShape をシートに追加
for i in range(hai.__len__()):
    shape = sheet.shapes.add_text_shape(
        x=i * 1600,
        y=1000,
        width=1500,
        height=2000,
        text=str(hai[i]),
        fill_color=0xFFFFE0  # 薄い黄色
    )
    # 背景色を設定
    shape.FillColor = 0xFFFFE0  # 薄い黄色
    # 枠線の色を設定
    shape.LineStyle = LineStyle.SOLID  # SOLID
    shape.LineWidth = 50  # 線の太さを50 (1/100 mm)
    shape.LineColor = 0x008000  # 緑
    # テキストを中央揃え
    shape.HoriJustify = 1  # CENTER
    shape.VertJustify = 1  # CENTER
    # フォントサイズを大きく
    shape.font_size = 48.0
    # フォントの指定
    shape.CharFontName = "GL-MahjongTile"

# 麻雀牌を並べる
for i, shape in enumerate(sheet.shapes):
    shape.PositionX = i * 1600  # X位置をずらす
    shape.PositionY = 1000  # Y位置を10 mmに設定

# 完成

麻雀用のフォント “GL-MahjongTile” を使って TextShape を使った例です。
フォント自体は PC にインストールしないと駄目なので、埋め込みはできません。

図形が貼り付けられると、フローチャートとかネットワーク図とかの生成ができるようになります。

Calc 内部からマクロで呼び出す

いままでの例は外部から Python コードを使って呼び出すのですが、Calc 内部から Python コードを呼び出すには、XSCRIPTCONTEXT を使わないといけません。ただし、ちょっと初期化コードが外部呼出しからとは異なるのがややこしいのですが、以下のように書けます。

import inspect
import os
import sys
from typing import Any, Tuple

# Ensure this script's directory is importable so the local excellike package resolves
BASE_DIR = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe())))
if BASE_DIR not in sys.path:
    sys.path.append(BASE_DIR)

from excellikeuno.table.sheet import Sheet 

# XSCRIPTCONTEXT に接続する
def connect_calc_script() -> Tuple[Any, Any, Sheet]:
    desktop = XSCRIPTCONTEXT.getDesktop()
    doc = desktop.getCurrentComponent()
    controller = doc.getCurrentController()
    sheet = Sheet(controller.getActiveSheet())
    return desktop, doc, sheet

def hello_to_cell():
    ( _, _, sheet ) = connect_calc_script()
    sheet.cell(0, 0).text = "Hello Excel Like for Python!"
    sheet.cell(0, 1).text = "こんにちは、Excel Like for Python!"
    sheet.cell(0,0).column_width = 10000  # 幅を設定

    cell = sheet.cell(0,1)
    cell.CellBackColor = 0x006400  # 濃い緑に設定
    cell.CharColor = 0xFFFFFF  # 文字色を白に設定

g_exportedScripts = (
    hello_to_cell,
)

こんな風にマクロを実行します。

connect_calc_script 部分を自前で書かないといけないのが難点なので、これは connect_calc にうまく吸収させる予定です。Calc と接続した後は、uno api は共通化されているので同じコードが動きます。つまり ExcelLikeUno ライブラリも外部からでも内部からもで動くということです。

内部で使える Python マクロで何が良くなるかと言うと、こんな風に vscode 使って Python コードが書けるという点です。Excel VBA 互換や、独自 Basic のコードだと付属のエディタでしか書けませんが、Python コードならば vscode で書けます。さらに言えば、git と連携もできるので、コードの保存が可能です。

ただし、コード保存の場所が

C:\Users\<ユーザー名>\AppData\Roaming\LibreOffice\4\user\Scripts\python\

というややこしい場所になっているのが難点で、excellikeuno ライブラリもここに配置しなければいけません。これも今後なんとかしていこうと考えています。

参照先

https://github.com/moonmile/ExcelLikeUno

README.md にもう少し詳しい説明があります。Linux 版や Docker 版などは、準備中。

カテゴリー: 開発, LibreOffice | LibreOffice Calc で使う Excel Like UNO を仮リリース はコメントを受け付けていません

論理削除と物理削除の続き 部品受注システムを Rust + SQL 文で解いてみる

前回の 論理削除と物理削除の続き(工場の部品受注システムの例)https://www.moonmile.net/blog/archives/11888 では、論理削除と物理削除の違いであまり差が出て来なかったので、もう少し踏み込んでみます。いわゆる SQL 文を直接書いて編集する例を見ていきましょう。

結論から言えば、SQL 文を直接書くときには deleted_at カラムを使った論理削除は面倒くさいし結構不利です。「論理削除がアンチパターン」と称される所以がここにあります。ただし、現在においては ORM を使って deleted_at カラムをうまく隠蔽する方法があるので、ORM を使っている限りはあまり不便を感じません。そういう意味では「論理削除はアンチパターン」と断言してしまうのは、ちょっと過去の話かもしれません。

論理削除パターン

-- 部品マスター
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 部品補助情報
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の調達先
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 受注情報
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);
 
-- 部品生産管理
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);
 
-- 部品の組み合わせテーブル(BOM)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);
 
-- BOM マスター
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL
);
 
-- 受注組み合わせ番号管理
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);
 
-- 顧客発注履歴
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);
 
-- 顧客マスター
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
 
-- サプライヤーマスター
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

物理削除パターン

-- 部品マスター(物理削除)
CREATE TABLE parts (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_number VARCHAR(64) UNIQUE NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 部品マスター履歴
CREATE TABLE parts_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    part_number VARCHAR(64) NOT NULL,
    part_name VARCHAR(255) NOT NULL,
    description TEXT NULL,
    unit_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    version INT NOT NULL,
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- BOM マスター(物理削除)
CREATE TABLE bom (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    product_number VARCHAR(64) UNIQUE NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT DEFAULT 1,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- BOM マスター履歴
CREATE TABLE bom_history (
    history_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    product_number VARCHAR(64) NOT NULL,
    product_name VARCHAR(255) NOT NULL,
    version INT NOT NULL,
    effective_date DATE NOT NULL,
    status ENUM('active', 'obsolete') DEFAULT 'active',
    effective_from TIMESTAMP NOT NULL,
    effective_to TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 部品補助情報(物理削除)
CREATE TABLE part_details (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    weight DECIMAL(10, 4) NULL,
    dimensions VARCHAR(255) NULL,
    material VARCHAR(128) NULL,
    storage_location VARCHAR(128) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の調達先(物理削除)
CREATE TABLE part_suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_part_number VARCHAR(64) NULL,
    supplier_price DECIMAL(10, 2) NOT NULL,
    lead_time_days INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id),
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 受注情報(物理削除)
CREATE TABLE purchase_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    po_number VARCHAR(64) UNIQUE NOT NULL,
    supplier_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    expected_delivery_date DATE NOT NULL,
    actual_delivery_date DATE NULL,
    status ENUM('pending', 'ordered', 'received', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
);

-- 部品生産管理(物理削除)
CREATE TABLE part_production (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    part_id BIGINT NOT NULL,
    production_date DATE NOT NULL,
    quantity_produced INT NOT NULL,
    quality_status ENUM('pass', 'fail', 'rework') DEFAULT 'pass',
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (part_id) REFERENCES parts(id)
);

-- 部品の組み合わせテーブル(BOM)(物理削除)
CREATE TABLE bom_items (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    bom_id BIGINT NOT NULL,
    part_id BIGINT NOT NULL,
    quantity INT NOT NULL,
    sequence INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (bom_id) REFERENCES bom(id),
    FOREIGN KEY (part_id) REFERENCES parts(id),
    UNIQUE KEY unique_bom_part (bom_id, part_id)
);

-- 受注組み合わせ番号管理(物理削除)
CREATE TABLE customer_orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_number VARCHAR(64) UNIQUE NOT NULL,
    customer_id BIGINT NOT NULL,
    bom_id BIGINT NOT NULL,
    order_date DATE NOT NULL,
    delivery_date DATE NOT NULL,
    quantity INT NOT NULL,
    status ENUM('pending', 'in_production', 'completed', 'cancelled') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_id) REFERENCES customers(id),
    FOREIGN KEY (bom_id) REFERENCES bom(id)
);

-- 顧客発注履歴(物理削除)
CREATE TABLE order_history (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_order_id BIGINT NOT NULL,
    status_changed_to VARCHAR(64) NOT NULL,
    changed_at TIMESTAMP NOT NULL,
    notes TEXT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (customer_order_id) REFERENCES customer_orders(id)
);

-- 顧客マスター(物理削除)
CREATE TABLE customers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    customer_code VARCHAR(64) UNIQUE NOT NULL,
    customer_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- サプライヤーマスター(物理削除)
CREATE TABLE suppliers (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    supplier_code VARCHAR(64) UNIQUE NOT NULL,
    supplier_name VARCHAR(255) NOT NULL,
    contact_person VARCHAR(128) NULL,
    phone VARCHAR(20) NULL,
    email VARCHAR(255) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

これをベースにして、Rust を使って SQL 文を直接書くコードを生成してみましょう。幸い?にして Rust では softdelete 系のライブラリが存在しないので、この手のコードは SQL 文を直接書くしかありません。まあ、探せば ORM があるんでしょうが、ここでは悪手のために犠牲になってもらいましょう。

ケース1

顧客から古い発注書に基づいて部品の発注を受けた。残念ながら過去の発注のために、発注書の中にある、部品番号は現在扱っていない。この部品番号を新しくして見積もり書を作成したい。

このときに、過去の発注書と合計金額を照合して、差分を計算しておきたい。

論理削除パターン

use sqlx::{MySql, Pool, Row};
use sqlx::mysql::MySqlPoolOptions;
use decimal::Decimal;

#[derive(Debug, Clone)]
struct OrderItem {
    part_number: String,
    part_name: String,
    quantity: i32,
    unit_price: Decimal,
    total_price: Decimal,
}

#[derive(Debug)]
struct OrderComparison {
    old_items: Vec<OrderItem>,
    new_items: Vec<OrderItem>,
    old_total: Decimal,
    new_total: Decimal,
    difference: Decimal,
}

async fn compare_orders_with_replacement(
    pool: &Pool<MySql>,
    old_bom_id: i64,
    old_part_id_b: i64,
    new_part_id_d: i64,
) -> Result<OrderComparison, sqlx::Error> {
    // 古い発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? 
         ORDER BY bi.sequence"
    )
    .bind(old_bom_id)
    .fetch_all(pool)
    .await?
    .into_iter()
    .map(|(part_number, part_name, quantity, unit_price)| {
        let total_price = unit_price * Decimal::from(quantity);
        OrderItem {
            part_number,
            part_name,
            quantity,
            unit_price,
            total_price,
        }
    })
    .collect();

    // 新しい発注書用に部品Bを部品Dに置き換え
    let mut new_items = old_items.clone();
    for item in &mut new_items {
        if item.part_number == "B" {
            // 部品Dの情報を取得
            let new_part: (String, String, Decimal) = sqlx::query_as(
                "SELECT part_number, part_name, unit_price FROM parts WHERE id = ? AND deleted_at IS NULL"
            )
            .bind(new_part_id_d)
            .fetch_one(pool)
            .await?;

            item.part_number = new_part.0;
            item.part_name = new_part.1;
            item.unit_price = new_part.2;
            item.total_price = new_part.2 * Decimal::from(item.quantity);
        }
    }

    let old_total: Decimal = old_items.iter().map(|i| i.total_price).sum();
    let new_total: Decimal = new_items.iter().map(|i| i.total_price).sum();
    let difference = new_total - old_total;

    Ok(OrderComparison {
        old_items,
        new_items,
        old_total,
        new_total,
        difference,
    })
}

※ 2025/12/30 追記
最初に AI に作成して貰ったときにここのSQL文には deleted_at IS NULL が付いていました。この場合、”古い部品調達まで調べる” ことが必要なので deleted_at の状態に関係なく=新旧関係なくデータを検索しなければいけません。この部分 AI でも間違えるので注意が必要です。

物理削除パターン

use sqlx::{MySql, Pool, Row};
use sqlx::mysql::MySqlPoolOptions;
use decimal::Decimal;

#[derive(Debug, Clone)]
struct OrderItem {
    part_number: String,
    part_name: String,
    quantity: i32,
    unit_price: Decimal,
    total_price: Decimal,
}

#[derive(Debug)]
struct OrderComparison {
    old_items: Vec<OrderItem>,
    new_items: Vec<OrderItem>,
    old_total: Decimal,
    new_total: Decimal,
    difference: Decimal,
}

async fn compare_orders_with_replacement(
    pool: &Pool<MySql>,
    old_bom_id: i64,
    old_part_number_b: &str,
    new_part_id_d: i64,
) -> Result<OrderComparison, sqlx::Error> {
    // 古い発注書から部品情報を取得。parts に存在しない場合は履歴テーブルの最新版を使用する
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        r#"
        WITH latest_history AS (
            SELECT ph.*, ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name) AS part_name,
            bi.quantity,
            COALESCE(p.unit_price, lh.unit_price) AS unit_price
        FROM bom_items bi
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE bi.bom_id = ?
        ORDER BY bi.sequence
        "#
    )
    .bind(old_bom_id)
    .fetch_all(pool)
    .await?
    .into_iter()
    .map(|(part_number, part_name, quantity, unit_price)| {
        let total_price = unit_price * Decimal::from(quantity);
        OrderItem {
            part_number,
            part_name,
            quantity,
            unit_price,
            total_price,
        }
    })
    .collect();

    // 新しい発注書用に部品Bを部品Dに置き換え
    let mut new_items = old_items.clone();
    for item in &mut new_items {
        if item.part_number == old_part_number_b {
            let new_part: (String, String, Decimal) = sqlx::query_as(
                "SELECT part_number, part_name, unit_price FROM parts WHERE id = ?"
            )
            .bind(new_part_id_d)
            .fetch_one(pool)
            .await?;

            item.part_number = new_part.0;
            item.part_name = new_part.1;
            item.unit_price = new_part.2;
            item.total_price = new_part.2 * Decimal::from(item.quantity);
        }
    }

    let old_total: Decimal = old_items.iter().map(|i| i.total_price).sum();
    let new_total: Decimal = new_items.iter().map(|i| i.total_price).sum();
    let difference = new_total - old_total;

    Ok(OrderComparison {
        old_items,
        new_items,
        old_total,
        new_total,
        difference,
    })
}

さて、2つの SQL でどちらが複雑でしょうか?

    // 古い発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? 
         ORDER BY bi.sequence"
    )

    // 古い発注書から部品情報を取得。parts に存在しない場合は履歴テーブルの最新版を使用する
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        r#"
        WITH latest_history AS (
            SELECT ph.*, ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name) AS part_name,
            bi.quantity,
            COALESCE(p.unit_price, lh.unit_price) AS unit_price
        FROM bom_items bi
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE bi.bom_id = ?
        ORDER BY bi.sequence
        "#
    )

見てわかる通り、物理削除パターンのほうが複雑です。まあ、これはあまりフェアではなくて、古いデータを取得しようとしたときには物理削除パターンのほうが手がかかるのは当たり前のことです。

これを通常の現状あるパーツから検索する SQL 文に置き換えると

論理削除パターン

    // 通常の発注書から部品情報を取得
    let items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ? AND p.deleted_at IS NULL
         ORDER BY bi.sequence"
    )

物理削除パターン

    // 通常発注書から部品情報を取得
    let old_items: Vec<OrderItem> = sqlx::query_as::<_, (String, String, i32, Decimal)>(
        "SELECT p.part_number, p.part_name, bi.quantity, p.unit_price
         FROM bom_items bi
         JOIN parts p ON bi.part_id = p.id
         WHERE bi.bom_id = ?
         ORDER BY bi.sequence"
    )

となるので、論理削除の場合は必ず deleted_at IS NULL の条件を付ける必要があります。これは論理削除の場合は弊害ですよね。
deleted_at IS NULL を付けるのを忘れると大変なことになります。逆に言えば、AI エージェントなどを使った場合には AI は忘れないので問題が出ないし、ORM を使う場合も問題が出ません。

たまに、データ量が多くなると deleted_at IS NULL の条件がパフォーマンスに影響を与えることがあるという記事がありますが、それはそもそもテーブル設計が悪いです。その場合は、逆にログやジャーナル用のテーブルから検索用のテーブルと現在用のテーブルに分けるという設計にすべきでしょう。
このあたり、架空の議論に陥りがちなので、このように具体例を示しています。

ケース2

もうひとつケースを考えてみましょう。

客先から発注書を受けたが、発注書に記載されている部品番号をシステムで検索すると出て来ない。どうやら、以前の見積もりを出した時には、調達可能だったもので組み合わせ番号を作成していたのだが、その後に廃盤になってしまったらしい。組み合わせ番号の整備が追い付かず、部品の組み合わせのほうが有効になっているが、部品番号を調べると画面に出て来ないという状況になってしまっている。

発注書に組み合わせ番号が書いてあったときに、その組み合わせに含まれる部品番号が有効であるかどうかのチェックを行いたい。1回目のチェックでは、現状調達できる部品だけでチェックをするが、2回目のチェックでは、履歴テーブルも参照して、過去に存在した部品番号も含めてチェックを行いたい。
1回目と2回目のチェックのメソッドを作成して欲しい。
入力は customer_orders.order_number としたい。

論理削除パターン

use sqlx::{mysql::MySqlPoolOptions, MySql, Pool, Row};

#[derive(Debug)]
struct PartCheck {
    part_number: String,
    part_name: Option<String>,
    quantity: i32,
    status: String, // "available" | "deleted" | "missing"
}

// 現在調達可能な部品だけを見る
async fn check_order_parts_current_logical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
          AND p.deleted_at IS NULL
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: Some(row.get("part_name")),
            quantity: row.get("quantity"),
            status: "available".to_string(),
        })
        .collect())
}

// 削除済みも含めて有効性を判定(論理削除なので履歴は無く、現行テーブルのdeleted_atで判断)
async fn check_order_parts_with_deleted_logical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity,
               p.deleted_at IS NULL AS is_available,
               p.deleted_at IS NOT NULL AS is_deleted
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        LEFT JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| {
            let part_number: Option<String> = row.try_get("part_number").ok();
            let status = if part_number.is_none() {
                "missing"
            } else if row.get::<bool, _>("is_available") {
                "available"
            } else if row.get::<bool, _>("is_deleted") {
                "deleted"
            } else {
                "missing"
            };
            PartCheck {
                part_number: part_number.unwrap_or_else(|| "(none)".to_string()),
                part_name: row.try_get("part_name").ok(),
                quantity: row.get("quantity"),
                status: status.to_string(),
            }
        })
        .collect())
}

物理削除パターン

use sqlx::{mysql::MySqlPoolOptions, MySql, Pool, Row};

#[derive(Debug)]
struct PartCheck {
    part_number: String,
    part_name: Option<String>,
    quantity: i32,
    status: String, // "available" | "historical" | "missing"
}

// 現在調達可能な部品だけを見る(partsのみ)
async fn check_order_parts_current_physical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        SELECT bi.part_id,
               p.part_number,
               p.part_name,
               bi.quantity
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        JOIN parts p ON p.id = bi.part_id
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: Some(row.get("part_name")),
            quantity: row.get("quantity"),
            status: "available".to_string(),
        })
        .collect())
}

// 履歴も参照して有効性を判定(最新履歴を採用)
async fn check_order_parts_with_history_physical(
    pool: &Pool<MySql>,
    order_number: &str,
) -> Result<Vec<PartCheck>, sqlx::Error> {
    let rows = sqlx::query(
        r#"
        WITH latest_history AS (
            SELECT ph.*,
                   ROW_NUMBER() OVER (PARTITION BY ph.part_id ORDER BY ph.effective_from DESC) AS rn
            FROM parts_history ph
        )
        SELECT
            COALESCE(p.part_number, lh.part_number) AS part_number,
            COALESCE(p.part_name, lh.part_name)   AS part_name,
            bi.quantity,
            CASE
                WHEN p.id IS NOT NULL THEN 'available'
                WHEN lh.history_id IS NOT NULL THEN 'historical'
                ELSE 'missing'
            END AS status
        FROM customer_orders co
        JOIN bom_items bi ON bi.bom_id = co.bom_id
        LEFT JOIN parts p ON p.id = bi.part_id
        LEFT JOIN latest_history lh ON lh.part_id = bi.part_id AND lh.rn = 1
        WHERE co.order_number = ?
        ORDER BY bi.sequence
        "#,
    )
    .bind(order_number)
    .fetch_all(pool)
    .await?;

    Ok(rows
        .into_iter()
        .map(|row| PartCheck {
            part_number: row.get("part_number"),
            part_name: row.try_get("part_name").ok(),
            quantity: row.get("quantity"),
            status: row.get("status"),
        })
        .collect())
}

見ての通り、過去のデータを調べる場合は、物理削除パターンのほうが SQL 文が複雑です。特に履歴テーブルを参照する場合には、最新の履歴を取得するためにウィンドウ関数を使う必要があり、SQL 文が大幅に複雑化します。
逆に言えば、過去のデータを調べない限り、物理削除のほうが有利というわけです。
つまりは、要件として「過去のデータを遡って調べることがあるのか?」をチェックしておけば、データ構造を決める上で論理削除にするか物理削除にするかという判断材料になります。
蛇足を言えば、「過去のデータを調べるか否か?」の条件を付けずに、テーブル構造の設計に「論理削除か物理削除か?」を持ち込むこと自体がアンチパターンという訳ですね。

余談ですが、複数テーブルを検索するときに is null チェックが各テーブルに付加されて面倒くさい/間違いが置きやすいという指摘がたびたびありますが、現実的にはそうはならないです。

select *
from A inner join B on A.id = B.a_id and B.deleted_at is null
     B inner join C on B.id = C.b_id and C.deleted_at is null
     C inner join D on C.id = D.c_id and D.deleted_at is null
where A.deleted_at is null;

一見すると、A,B,C,D の全てに is null の条件を付けないような感じがしますが、

select *
from A inner join B on A.id = B.a_id 
     B inner join C on B.id = C.b_id 
     C inner join D on C.id = D.c_id 
where A.deleted_at is null;

運用上は、A.deleted_at is null の条件だけで十分です。

これは SQL の結合の仕組みによるもので、A.deleted_at is null の条件で A とは関係ないレコードを、B,C,D から拾ってくることはないからです。逆に B.deleted_at is not null のレコードを参照していたら、それは運用上のミスか、画面設計でのガード掛かっていないためです。

このあたりも考察していくと、実は論理削除をするにしても、メインとなるテーブルだけに注目すればよいという結論です。

カテゴリー: 開発, データベース | 論理削除と物理削除の続き 部品受注システムを Rust + SQL 文で解いてみる はコメントを受け付けていません