Exposure Notifications/COCOAのビーコンを収集して解析する

Google/Apple の提供する Exposure Notifications では BLE でビーコンを発信しています。内部的には 32 バイトなデータを Advertising/Broadcasting ってことになります。

コード自体は、openCACAO/CacaoBeacon: https://github.com/openCACAO/CacaoBeacon でビーコンの受信&収集コードを公開しています。CacaoBeaconMonitor が Android アプリです。iOS 版はありません。ENs では、ビーコンにある ID を受信するのですが、iOS の場合はこれが OS 側で塞がれているためです。なので、ビーコンのモニタリングとしては Android あるいは Windows を使います。

ノートパソコンを持ってうろうろするのは大変なので(定点観測ならばノートPCでも十分ですが)、Android のスマホを使います。

感染が拡大したので蒐集してみる

コード作ったのが4か月前で、去年の秋には東京都の感染者がぐんぐん減っている時期で、実験的なデータがとりずらい状態でした。で、まあ、とりあえず放置状態にしてあったのですが、第6波の勢いは尋常ではなく、現時点で東京都では2万/日をカウントしています。実に去年の秋の1000倍近い数字になってしまったわけですが。

COCOA自体の稼働率は、おそらく人口比で3割を超えています。通勤のような社会人に限っていれば、半分以上はあるっぽいです。 CacaoBeaconMonitor の場合は、COCOA から発信するビーコンを受けて RPI を保持していく Google/Apple の Exposure Notifications のクローンのような動作をさせているので、多分正確に収集ができます。

陽性者とのマッチングは別途、probetek で zip をダウンロードしてきて、SQL Server 上でクエリを書いています。

実測データ

2022年2月3日に、池袋を2時間ほど徘徊したデータ

https://1drv.ms/u/s!AmXmBbuizQkXgrlPo98iECzyoSgy8w?e=nr7QM0

上記から SQLite のデータがダウンロードできます。東武東上線で池袋まで行って、東武デパートで食事をして、帰ってくるまでの2時間のデータになります。

収集データの解説

テーブル構造は、以下の通り

CREATE TABLE RPI (
    Id INTEGER NOT NULL CONSTRAINT PK_RPI PRIMARY KEY AUTOINCREMENT,
    Key BLOB NULL,
    Metadata BLOB NULL,
    StartTime TEXT NOT NULL,
    EndTime TEXT NOT NULL,
    RssiMin INTEGER NOT NULL,
    RssiMax INTEGER NOT NULL,
    MAC INTEGER NOT NULL
)

ビーコンの受信開始(StartTime)と受信終了(EndTime)で接触時間が計測できます。ただし、ビーコンの受信データだけだと、発信のほうが10分で切り替わるので10分以内しか判別できません。別途、陽性者のTEKをダウンロードしてきて、受信したRPIを照合させると10分以上の接触が確認できます。実際の ENs が「接触の可能性あり」で通知しているのはこの部分です。

電波強度(RssiMax)を見ると、どれだけ近接しているかがわかります。私自身 iPhone に COCOA を入れているので、ひとつだけはかなり近いデータになっています。それ以外は、電車やホームでのすれ違い、エスカレーターで並んでいたときと考えられます。データの収集は一瞬でも近づけばデータとして保持されるので、きちんと解析するときは、近接している時間(EndTime – StartTime)と、電波強度(RssiMax)を見るとよいでしょう。

実際に実験をしてみると、バスが通ったり、電車が通過したときでも同時接続数が40程度の爆上がりします。いわゆる一瞬のすれ違いでも計測されるということです。

COCOA が使う ENs の仕組みは完全に公開されているとは言えませんが、おおまかなところはコードで示されています。実際の照合のところは数式でしか示されていないのが残念なところですが、公式で言われている通り、電波強度と近接時間の両方をみて判断して1mと15分以上という値を割り出しています。

TEK データのダウンロード

COCOA を通して陽性登録された TEK のデータを probetek コマンドでダウンロードします。

dotnet run --all

2/4時点で、50万件のTEKがあります。TEK は日単位で変わるので、おおむね7で割ると登録数になります。おおむね7万件というところでしょう。このデータは全国なので、2,3割の陽性者が登録している計算になります。実際のところは、厚生労働省のページ https://www.mhlw.go.jp/stf/seisakunitsuite/bunya/cocoa_00138.html で確認ができます。

この TEK データを SQL Server にアップロードします。

dotnet run --update-tek

TEK から、その日の 144個(10分単位)の RPI を生成して EXRPI テーブルに挿入しています。

と思ったのですが、データ量が 50万 x 144 で 7200万件になるので尋常ではありません。そのまま動かすとPCのメモリのデータベースもいっぱいになってしまうので、照合の部分は考え直しましょう。

実際の Google/Apple の ENs では C++ でバイナリ照合しているのでメモリは枯渇しません。どうやら照合用のツールを別途作る必要がありそうです。

カテゴリー: 開発 | Exposure Notifications/COCOAのビーコンを収集して解析する はコメントを受け付けていません

Redmine の docker を作る

Hyper-V + WLS2 な環境で Redmine を構築してもよいのですが、いろいろ面倒(特に Ruby on Rails が面倒)なので、docker を使って構築します。

Windows 上で構築すると MySQL が相乗りになったり、Ruby のパスを設定したりといろいろ面倒で、仮想環境の Linux に入れていたのですが、.NET6 + gRPC の実験用の構築です。

docker-compose を使え

その昔、ひとつのコンテナに ruby + mysql を入れてあれこれと構築していた頃があったのですが、結果的にひとつのコンテナにひとつのアプリを入れてしまったほうが楽ですね。

Redmine – Official Image | Docker Hub https://hub.docker.com/_/redmine/

なので、素直に docker-composer を使います。以前の Docker Desktop ではうまく動かないことが多かったのですが、現在は結構スムースっぽい。

以下を、docker-compose.yml というファイル名で保存します。

公式との違いは、

  • MySQL を外から参照できるようにポートフォワードしている(後で gRPC で使う)
  • command で絵文字が使えるように utf8mb4 を指定する。
    これがないと、Redmine で日本語を使ったときに落ちる。
version: '3.1'

services:

  redmine:
    image: redmine
    restart: always
    ports:
      - 8081:3000
    environment:
      REDMINE_DB_MYSQL: db
      REDMINE_DB_PASSWORD: example
      REDMINE_SECRET_KEY_BASE: supersecretkey

  db:
    image: mysql:5.7
    restart: always
    ports:
      - 33061:3306
    environment:
      MYSQL_ROOT_PASSWORD: example
      MYSQL_DATABASE: redmine
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

パスワードは面倒なので「example」のままで使っています。

この例では、

  • Redmine に localhost:8081 で接続
  • MySQL にポート番号 33061 で接続
  • mysql -u root -p で、パスワード example で接続できる

このあとで、docker-compose.yml と同じディレクトリで、以下を行えば ok

# コンテナの作成
docker-compose create
# コンテナの実行
docker-compose start

名前を間違ったりポートを間違ったりうまく作れなかったときは、Docker Desktop でコンテナを削除して何度も作り直します。

この例では、

こんな感じの2つのコンテナがひとつにまとめられます。

ブラウザで確認

localhost:8081 で確認します。初期パスワードは、admin/admin です。

初期データ(ロールやトラッカーなど)が入っていないので、デフォルト値のロードをします。このとき、文字コードが、utf8mb4 あるいは utf8 になっていないと、redmine 側でエラーになります。このときは、docker-compose の戻って、再構築します。

MySQL Workbench で確認

docker 内の mysql に接続します。

無事、テーブル構造が取得できています。

これを使って、.NET6 から CURD 用のエンティティクラスを作って、gRPC の *.proto をコード生成してやれば、適当なフロントエンドが作れるかも、ってところまで。

ちなみに、Oracle 19c の場合は 8GB 位メモリを持っていかれるのですが、redmine の場合は、512MB 位で済みます。これだとローカルPC内で動かしても大丈夫そう。oracle 19c のほうは、別のマシンに移動しました。

カテゴリー: 開発 | Redmine の docker を作る はコメントを受け付けていません

Oracle Database 19c な docker 環境の構築

基本的に Linux な環境で構築する場合 [Oracle Database] 公式Docker Imageを利用してOracle Database 19c環境を構築してみた | | IT Edge Blog https://itedge.stars.ne.jp/docker_image_oracle_database_19c/ と同じなのですが、Windows 10 の場合 image をどのように作るのか?の問題があります。

で、WLS2 で作ったらあっさりできたので、メモ代わりの残しておきます。

19c な Docker Image を作る

Oracle 公式では、12c https://hub.docker.com/_/oracle-database-enterprise-edition までしか用意されていません。結構古いものがのっかったままなので、どうするのかというと、Github にある Oracle の公式手順で作成していきます。

docker-images/OracleDatabase/SingleInstance at main · oracle/docker-images https://github.com/oracle/docker-images/tree/main/OracleDatabase/SingleInstance

ここの手順にしたがって、

$ ./buildContainerImage.sh

してから、

$ docker run –name

すればよいのです。

Linux 環境だと sh があるので、 buildContainerImage.sh が動くのですが、Windows 環境の場合はどうするのか?と言えば、WLS2 を使います。本当は Docker Image をバイナリで出力してくれればいいのですが、このスクリプトはそのまま docker に乗せてしまうので、さて、どうしたものかという感じではあるのですが。

大丈夫。WLS2 とホストな Windows 10(あるいは Windows 11)は内部的にはつながっているので、WLS2 から docker に乗せたもは Windows のほうの docker に乗せられます。というか、Windows のほうが WLS2 の docker を借用するようになっているのかもしれません。

まずは、git clone で oracle/docker-images を丸ごと取得

git clone git@github.com:oracle/docker-images.git

WLS2 を開いて、docker-images/OracleDatabase/SingleInstance/dockerfiles フォルダーへ移動

Oracle 本家から LINUX.X64_193000_db_home.zip をダウンロードして、19.3.0 に置く。zip は解凍せずにそのままでok。

例えば 19.c(19.3.0)で、Enterprise Edition を入れる場合は以下のように指定。

./buildDockerImage.sh -v 19.3.0 -e

これを実行すると、oracle/database の docker image が Windows のほうに追加されます。

コンテナを作る

コンテナを作るのは、Windows のほうで可能です。コマンドラインで次を実行します。

docker run --name oracle19c -p 1521:1521 -p 5500:5500 oracle/database:19.3.0-ee

ホストとなる Windows のほうでは Oracle が入っていないので、ポート番号は 1521 のままにしておきます。ホストとなる Windows のポート番号を変えておきたいときは -p 15210:1521 のようにします。Windows 側で localhost:15210 につなげたときに docker 側で 1521 につながるようになります。

docker run に渡すパラメータは以下の通りです。

docker run --name <container name> \
-p <host port>:1521 -p <host port>:5500 \
-e ORACLE_SID=<your SID> \
-e ORACLE_PDB=<your PDB name> \
-e ORACLE_PWD=<your database passwords> \
-e INIT_SGA_SIZE=<your database SGA memory in MB> \
-e INIT_PGA_SIZE=<your database PGA memory in MB> \
-e ORACLE_EDITION=<your database edition> \
-e ORACLE_CHARACTERSET=<your character set> \
-e ENABLE_ARCHIVELOG=true \
-v [<host mount point>:]/opt/oracle/oradata \
oracle/database:19.3.0-ee

面倒なので、コンテナを作成したのですが、気を付けるポイントとしては、

  • ORACLE_SID のデフォルトが「ORCLCDB」
  • ORACLE_PDBのデフォルトが「ORCLPDB1」
  • ORACLE_PWDは、コンテナの実行時に自動的に決まります。

sys で接続確認

Windows のほうに sqlplus のクライアントだけを入れておくと、接続確認ができます。

sqlplus sys/siosWOA6SKw=1@localhost:1521/ORCLCDB as sysdba

ログインユーザーと表領域の作成

ログインユーザーを作ってためしておきます。

あとで、.NET から接続確認したいので redmine ユーザーを作ります。

alter session set container=ORCLPDB1;

create tablespace "redminets" datafile '/opt/oracle/oradata/ORCLCDB/redminets.dbf' size 500M
 AUTOEXTEND ON NEXT 100M MAXSIZE 1G LOGGING EXTENT MANAGEMENT
 LOCAL SEGMENT SPACE MANAGEMENT AUTO;

create user redmine
 identified by redmine
 default tablespace "redminets"
 account unlock ;

GRANT connect TO "REDMINE";
GRANT CREATE SESSION TO "REDMINE";
GRANT "RESOURCE" TO "REDMINE";
ALTER USER "REDMINE" DEFAULT ROLE ALL;

あとは、sqlplus での接続の仕方が違うので、注意しておきましょう。SID とサービス名が初期値で少しややこしくなています。

□docker内で
sqlplus redmine/redmine@orclpdb1
sqlplus redmine/redmine@localhost:1521/ORCLCDB

□windows側から
sqlplus sys/siosWOA6SKw=1@localhost:1521/ORCLCDB as sysdba
sqlplus redmine/redmine@localhost:1521/orclpdb1

lsnrctl status の結果がこちら

メモリに注意

と、ここまで書きましたが、実は docker に oracle database 19c を入れるのはあまりお勧めできません。Oracle 公式が提供している docker images 作成用のスクリプトが非常に多くのメモリを利用する設定しく、コンテナを動かすと 16 GB ほど使われます。

docker stats で調べると 8GB 位つかっています。

こんなに使われると、ホスト側の Windows が死んでしまうので、.wslconfig で制限をします。この加減がよくわからないのですが、まあ、Hyper-V の仮想環境で Windows Server + Oracle を作ったときと同じくらい喰うのはどうなの?って感じです。環境的には、可搬性があるからいいけど。

Docker+WSL2の環境でVmmemのメモリ量が巨大になるのを制限する – いろいろ備忘録日記 https://devlights.hatenablog.com/entry/2021/10/28/073000

カテゴリー: 開発 | Oracle Database 19c な docker 環境の構築 はコメントを受け付けていません

Oracle Database 21c で LINQ を使う準備

Hyper-V の Windows Server に Oracle 21c を入れて、LINQ が使えるようにするところまでのメモ書きです。

Oracleは 11g 以来(実務だと9i あたりか)なので、途中にかなり間があいていますが、インストールとかデータベース作成とか落とし穴的なところは以前と変わらず。ちなみに、Java で作ってある超遅いインストーラは 21c から抜群に早くなっています。平行して試した 19c だと遅いままなので、おそらく 21c から最適されていた模様です。

おそらくお手軽なのは、Oracle Cloud を使うようなのですが、今回は訳あってオンプレミスな環境でテストをします。

仮想環境の構築

  • Hyper-V 上に Windows Server 2022 をインストール
  • メモリを 8GB、ストレージを 64GB 割り当て
  • ストレージは、ホストの SSD 上に作成する(スピードが2倍位違います)

動かしてみると常時 5GB 程度のメモリを使っているので、8GB 程度にするほうがよさそうです。ただし、Oracle 21c インストール時に「メモリが足りないけど続行するか?」のメッセージが出るので、余裕があれば 16GB 程度割り当てたほうがよさそう。が、実際のところ、仮想環境に 16GB 割り当てるのは難しそうなので、8GB 割り当てます。

Azure などのクラウド上で Windows Server を構築する場合、メモリが 8GB になると結構な課金が発生するので注意してください。4GB だと、インストール時に結構辛いかもしれません。

試験環境での実験なので、Express ではなく本家のものを使います。

Oracle Database 21c Download for Microsoft Windows x64 https://www.oracle.com/database/technologies/oracle21c-windows-downloads.html

以前は製品版を使うとライセンスサーバーを必要としていたのですが、今はどうなのだろう?

.NET6 の環境構築のための Visual Studio 2022 を入れます。

Visual Studio 2022 コミュニティ エディション – 最新の無料バージョンをダウンロードする https://visualstudio.microsoft.com/ja/vs/community/

dotnet & Visual Studio Code で軽量化することも可能ですが、NuGet の扱いとかデバッグ出力とかの扱いで面倒がないので VS2022 を入れてします。

Oracle Database 21c のインストールと初期設定

実は、Oracle 21c からプラカブルデータベースが必須になりました。インストールの選択に非PDBの選択肢がなくなっています。

  • CDB:コンテナデータベース
  • PDB:プラカブルデータベース

Oracle の場合、「データベース」という名の付くものが色々あって結構ややこしいです。

SQL Server の場合、インスタンス > データベース >テーブルのように比較的単純なのですが、

Oracle の場合、グローバルデータベース(インスタンス)>コンテナ(コンテナデータベース)>プラカブデータベース>テーブル という形で、いたるところで「データベース」が出てきます。

以前、あったテーブルスペース(表領域)はどこにいったのでしょう?という疑問があるのですが、これは後で調べます。スクリプトでデータベース(プラカブデータベース)を作り限り、従来通りの TABLESPACE も使えているようです。

インストール時に「ソフトウェアのみインストールする」にして、あとからデータベースを作成しています。

  • グローバルデータベース:orcl.mshome.net
  • プラカブルデータベース:orcldb

sqlplus / as sysdb で、次の状態まで確認します。

ORALCE_BASE と ORACLE_HOME

ORACLE_BASE を c:\app\oracle にして作成したのですが、tnsnames.ora 等のファイル位置が 19c と 21c では異なっています。ORACLE_HOME に下にはありません。

tnsnames.ora 等のファイルは以下にあります。

C:\app\oracle\homes\OraDB21Home1\network\admin

どうやら、$ORACLE_BASE/homes 配下に作成されるようです。

以下の例では、

  • ホスト名:oracle21sv
  • Oracle で設定される?サーバー名:oracle21sv.mshome.net
  • サービス名:orcldb.mshome.net
  • システム識別子(SID):ORCL

となっています。

listener.ora

# listener.ora Network Configuration File: C:\app\oracle\homes\OraDB21Home1\NETWORK\ADMIN\listener.ora
# Generated by Oracle configuration tools.

SID_LIST_LISTENER =
  (SID_LIST =
    (SID_DESC =
      (SID_NAME = CLRExtProc)
      (ORACLE_HOME = C:\app\oracle\product\21.3.0\dbhome_1)
      (PROGRAM = extproc)
      (ENVS = "EXTPROC_DLLS=ONLY:C:\app\oracle\product\21.3.0\dbhome_1\bin\oraclr.dll")
    )
  )

LISTENER =
  (DESCRIPTION_LIST =
    (DESCRIPTION =
      (ADDRESS = (PROTOCOL = TCP)(HOST = oracle21sv.mshome.net)(PORT = 1521))
      (ADDRESS = (PROTOCOL = IPC)(KEY = EXTPROC1521))
    )
  )

tnsnames.ora

# tnsnames.ora Network Configuration File: C:\app\oracle\homes\OraDB21Home1\NETWORK\ADMIN\tnsnames.ora
# Generated by Oracle configuration tools.

ORACLR_CONNECTION_DATA =
  (DESCRIPTION =
    (ADDRESS_LIST =
      (ADDRESS = (PROTOCOL = IPC)(KEY = EXTPROC1521))
    )
    (CONNECT_DATA =
      (SID = CLRExtProc)
      (PRESENTATION = RO)
    )
  )

ORCL =
  (DESCRIPTION =
    (ADDRESS_LIST =
      (ADDRESS = (PROTOCOL = TCP)(HOST = localhost)(PORT = 1521))
    )
    (CONNECT_DATA =
      (SERVICE_NAME = orcldb.mshome.net)
    )
  )

このあたりの設定がややこしいので、

  • lsnrctl status
  • tnsping <システム識別子(SID)>

を活用して事前にチェックしておきます。

うまく TNS の設定ができていれば tnsping orcl がで tnsnames.ora の設定が取得できます。

確か、 listener.ora と tnsnames.ora を手作業で修正した場合は、lsnrctl を再起動させます。

  • lsnrctl start
  • lsnrctl stop

データベースの作成

昔ながらのテーブルスペースを使った方式です。これいいかわからないので、これは後で修正。

CREATE TABLESPACE REDMINETS DATAFILE 
 'C:\app\oracle\oradata\ORCL\DATAFILE\REDMINETS.dbf' 
  SIZE 1G AUTOEXTEND ON NEXT 100M MAXSIZE UNLIMITED;

CREATE USER redmine IDENTIFIED BY redmine 
  DEFAULT TABLESPACE REDMINETS 
  TEMPORARY TABLESPACE TEMP 
  ACCOUNT UNLOCK ;

GRANT UNLIMITED TABLESPACE TO redmine;
GRANT CREATE SESSION TO redmine;
GRANT CONNECT TO redmine;
GRANT RESOURCE TO redmine;
ALTER USER "REDMINE" DEFAULT ROLE ALL;

redmine という名前でユーザーを作成しておきます。

  • 表領域 REDMINETS
  • ユーザー名 redmine(内部では自動的に大文字になるので、正確には REDMINE です)
  • パスワード redmine

権限が少し過剰ですが、ひとまずこれで Ok です。この使い方は、いまとなっては少しイリーガルなので、プラカブルデータベースを使った方式に直します。

この状態で、sqlplus でログインできるようになります。

sqlplus redmine/redmine@orcl

最後の orcl は、tnsnames.ora に設定したシステム識別子(SID)を使います。

tnsnames.ora が間違っている場合は、「ORA-12154: TNS: 指定された接続識別子を解決できませんでした」のエラーがでます。

ユーザー名あるいはパスワードが間違っている場合は「ORA-01017: invalid username/password; logon denied」です。

ほかにも、サービス名が間違っている、サーバー名が間違っている、場合もあるので、lsnrctl status と tnsping を使ってチェックしていきます。

テーブルの作成

テスト用の Books テーブルを作成します。

create table books (
  id number(16) not null,
  title varchar2(20),
  author varchar2(20),
  publisher varchar2(20)
);

仮データの挿入

insert into books values ( 1, 'Oracle入門', 'A', '出版社' );
insert into books values ( 2, 'SQL Server入門', 'B', '出版社' );
insert into books values ( 3, 'MySQL入門', 'A', '同人誌' );

テストプロジェクトの作成

接続確認をするプロジェクトを作成します。

dotnet new console --name DbConnectSample

NuGetで、ライブラリを追加

  • Microsoft.EntityFrameworkCore
  • Oracle.EntityFrameworkCore ODP.NET EF Core
  • Oracle.ManagedDataAccess.Core ODP.NET Core
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.12" />
		<PackageReference Include="Oracle.EntityFrameworkCore" Version="5.21.4" />
		<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="3.21.4" />
	</ItemGroup>
</Project>

Oracle.EntityFrameworkCore と Microsoft.EntityFrameworkCore のバージョンを 5 で揃えておきます。これは現時点では、 Oracle.EntityFrameworkCore は EF Core 5 までしか対応していないためです。

Oracle.ManagedDataAccess.Core は Oracle への接続のために使っています。

実験コードは以下の通りです。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Oracle.ManagedDataAccess.Client;

Console.WriteLine("Hello, Oracle World!");
Console.WriteLine("use DbContext");
var db = new OracleDbContext();
var items = db.Books.ToList();
foreach (var it in items)
{
    Console.WriteLine($"{it.ID} {it.TITLE}");
}

public class OracleDbContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var builder = new OracleConnectionStringBuilder();
        builder.UserID = "redmine";
        builder.Password = "redmine";
        builder.TnsAdmin = @"C:\app\oracle\homes\OraDB21Home1\network\admin";
        builder.DataSource = "ORCL";
        string cnstr = builder.ConnectionString;
        optionsBuilder.UseOracle(cnstr);
    }
    public DbSet<BOOKS> Books => Set<BOOKS>();
}

/*
SQL> desc books ;
 名前                                                  NULL?    型
 ----------------------------------------------------- -------- ------------------------------------
 ID                                        NOT NULL NUMBER(16)
 TITLE                                              VARCHAR2(20)
 AUTHOR                                             VARCHAR2(20)
 PUBLISHER                                          VARCHAR2(20)
 
 */
[Table("BOOKS")]    // これが必須
public class BOOKS
{
    [Key]
    public Int64 ID { get; set; }
    public string TITLE { get; set; } = "";
    public string AUTHOR { get; set; } = "";
    public string PUBLISHER { get; set; } = "";
}

Oracle のほうではテーブル名やカラム名を大文字で保持しているようなので、エンティティクラスのほうも大文字にしておきます。ただし、テーブル名はなぜかそのままでは認識できず Table 属性で指定します(おそらく ODP.NET EF Core のほうの不具合ような気も)。

実行結果

大文字のまま扱ってもよいのですが、C# の命名規約上のちのちややこしいことになるので、Column 属性で変更しておきます。

[Table("BOOKS")]
public class Books
{
    [Key]
    [Column("ID")] public Int64 Id { get; set; }
    [Column("TITLE")] public string Title { get; set; } = "";
    [Column("AUTHOR")] public string Author { get; set; } = "";
    [Column("PUBLISHER")] public string Publisher { get; set; } = "";
}

これでうまくC#の命名規則にあわせられます。

カテゴリー: 開発 | Oracle Database 21c で LINQ を使う準備 はコメントを受け付けていません

既存のMySQLからEF CoreのEntityを作成する

MySQLの場合、コードファースト的にコードからデータベースを更新していけばよいのですが、既存のデータベースから Entity を作りたいときがあります。まあ、先日てもとの Redmine をバージョンアップさせて、せっかくなので .NET6 からアクセスしようと思って、さて、と思案した経過を残しておきます。

redmine.projects のエンティティクラスを手作業で作る

一番手っ取り早いのは、手作業でエンティティクラスを作ることです。エンティティクラスは単純な値クラスなので、プロパティを並べれば ok.

MySQL Workbench の結果から、ちまちまと C# のクラスを作るか、スキーマを参照しながら手作業で作ります。

CREATE TABLE `projects` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL DEFAULT '',
  `description` text,
  `homepage` varchar(255) DEFAULT '',
  `is_public` tinyint(1) NOT NULL DEFAULT '1',
  `parent_id` int DEFAULT NULL,
  `created_on` timestamp NULL DEFAULT NULL,
  `updated_on` timestamp NULL DEFAULT NULL,
  `identifier` varchar(255) DEFAULT NULL,
  `status` int NOT NULL DEFAULT '1',
  `lft` int DEFAULT NULL,
  `rgt` int DEFAULT NULL,
  `inherit_members` tinyint(1) NOT NULL DEFAULT '0',
  `default_version_id` int DEFAULT NULL,
  `default_assigned_to_id` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_projects_on_lft` (`lft`),
  KEY `index_projects_on_rgt` (`rgt`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 ;
  • NULL 許可の部分をチェック
  • tinyint(1) を bool に直す
  • timestamp を DateTime あるいは DateTimeOffset に直す
  • 主キーに Key 属性をつける

部分を注意すれば比較的簡単に C# のエンティティクラスができます。

public class projects
{
    [Key]
    public int id { get; set; }
    public string name { get; set; } = "";
    public string? description { get; set; }
    public string? homepage { get; set; }
    public bool is_public { get; set; }
    public int parent_id { get; set; }
    public DateTime created_on { get; set; }
    public DateTime updated_on { get; set; }
    public string identifier { get; set; } = "";
    public bool status { get; set; }
    public int? lft { get; set; }
    public int? rgt { get; set; }
    public int inherit_members { get; set; }
    public int? default_version_id { get; set; }
    public int? default_assigned_to_id { get; set; }
}

個人的なプロジェクトならば、これで十分です(自分で趣味的に Redmine を扱うにもこれで十分)。

しかし、命名規則が C# にあっていないために大量に警告が出ます。さらに、仕事で使う場合にはプロパティ名自体が開発プロジェクト全体で使うために、あちこちに MySQL 特有のケバブケース(アンダースコアを使う命名法)が散らかってしまいます。たとえば、is_public ではなくて IsPublic にしておきたいわけで、ここで MySQL のカラム名と C# のプロパティ名の変換が発生します。

名前の変換は System.ComponentModel.DataAnnotations.Schema にある属性を使って切り替えます。

using System.ComponentModel.DataAnnotations.Schema;

[Table("proejcts")]
public class Project
{
...
    [Column("is_public")]
    public bool IsPublic { get; set; }
...
}

Table 属性と Column 属性をちまちまと指定していけば、

  • MySQL 側のカラム名を Column で指定
  • プロパティ名は C# の命名規約に合わせる

ことができます。ちなみに MySQL の設定によってはテーブル名やカラム名の大文字小文字が区別されるため、環境にそろえようとすると(特に Linux上)、この属性は必須になります。

これを DbContext を継承したクラスに設定すれば、自由に LINQ が使えるようになります。

public class RedmineDataContext : DbContext
{
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var builder = new MySqlConnectionStringBuilder();
        builder.Server = "localhost";
        builder.Database = "redmine";
        builder.UserID = "redmine";
        builder.Password = "redmine";
        optionsBuilder.UseMySQL(builder.ConnectionString);
    }
    public DbSet<Project> Project => Set<Project>();
}

さて、問題はここから、この手作業で作ったエンティティクラスをどのように大量に作っているのか?を考えます。数個のテーブルならば手作業で作ってよいのですが、数十個になると結構大変。100以上になるとちょっと手作業では太刀打ちできない。しかも、いつの間にかデータベースのほうから更新する場合に再び手作業に変えていくのはもっと大変な作業です。

コードファースト的にコードからデータベースを更新(マイグレーション)していけばいいのですが、既存のDBを扱う場合にはなかなかコードファーストという訳にはいきません。

テーブル構成を取得する

結論から言いますが、テーブル構成をちまちま取得するよりも、「dotnet ef dbcontext scaffold」でエンティティクラスを一気に作ったほうが楽です。dontet の scaffold は MySQL に正式には対応していないようなのですが、一応は動きます。

desc で取得する

MySQL のコマンドラインから desc <テーブル名> で取得が可能です。

MariaDB [redmine]> desc projects ;
+------------------------+--------------+------+-----+---------+----------------+
| Field                  | Type         | Null | Key | Default | Extra          |
+------------------------+--------------+------+-----+---------+----------------+
| id                     | int(11)      | NO   | PRI | NULL    | auto_increment |
| name                   | varchar(255) | NO   |     |         |                |
| description            | text         | YES  |     | NULL    |                |
| homepage               | varchar(255) | YES  |     |         |                |
| is_public              | tinyint(1)   | NO   |     | 1       |                |
| parent_id              | int(11)      | YES  |     | NULL    |                |
| created_on             | timestamp    | YES  |     | NULL    |                |
| updated_on             | timestamp    | YES  |     | NULL    |                |
| identifier             | varchar(255) | YES  |     | NULL    |                |
| status                 | int(11)      | NO   |     | 1       |                |
| lft                    | int(11)      | YES  | MUL | NULL    |                |
| rgt                    | int(11)      | YES  | MUL | NULL    |                |
| inherit_members        | tinyint(1)   | NO   |     | 0       |                |
| default_version_id     | int(11)      | YES  |     | NULL    |                |
| default_assigned_to_id | int(11)      | YES  |     | NULL    |                |
+------------------------+--------------+------+-----+---------+----------------+
15 rows in set (0.01 sec)

MariaDB [redmine]>

これをテキストに落としてからちまちま適当なスクリプトでコンバートしてもよいのですが、実は FromSqlRaw メソッドを使って直接 SQL 文を実行しても取得できます。

public class DescResult
{
    [Key]
    public string Field {  get; set; }
    public string Type { get; set; }
    public string Null { get; set; }
    public string? Key { get; set; }
    public string? Default { get; set; }
    public string? Extra { get ; set; }
}

public class RedmineDataContext : DbContext
{
...
    public DbSet<DescResult> DescResult => Set<DescResult>();
}

var cnn = context.Database.GetDbConnection() as MySqlConnection;
var result = context.DescResult.FromSqlRaw("DESC projects");
Console.WriteLine("\nDESC projects");
Console.WriteLine("Field    Type    Null    Key Default Extra");
foreach ( var it in result )
{
    Console.WriteLine($"{it.Field}\t{it.Type}\t{it.Null}\t{it.Key}\t{it.Default}\t{it.Extra}");
}

DbContext に DescResult のように結果を含めるクラスが必要になるのが不満ではありますが、こんな風に取れます。

この中身をチェックして自前でエンティティクラスを取得してもよいでしょう。

GetSchema メソッドを使う

実は DbConnection クラスには GetSchema というスキーマ(テーブル構成)を取得するための便利なメソッドがついています。かつて VB で使っていた ADO に対する ADOX のようなもので、対象となるデータベースの構成情報が取得できます。これは、SQL Server に限らず、MySQL でも取れるのでこれを活用できます。

neue cc – Micro-ORMとテーブルのクラス定義自動生成について http://neue.cc/2013/06/30_411.html

を参考にしながら作っていきましょう。いまとなっては dynamic だと辛いので、エンティティクラスを作ります。

ところで、GetSchema に渡すテーブル名?はデータベースそれぞれになるので、Oracle とか SQL Server の場合は独自に探さないといけません。他のデータベースはさておき、MySQL の場合は、GetSchema(“COLUMNS”) で構成情報が取得できます。

エンティティクラスを作成するために(仕方がなく手作業になりますが)、列名の詳細が必要になります。

MySQL :: MySQL 8.0 Reference Manual :: 26.3.8 The INFORMATION_SCHEMA COLUMNS Table https://dev.mysql.com/doc/refman/8.0/en/information-schema-columns-table.html

MySQL のマニュアルを参照しながら、ちまちまと作ってもよいのですが、desc コマンドでも取れます。

MariaDB [redmine]> desc INFORMATION_SCHEMA.COLUMNS ;
+--------------------------+---------------------+------+-----+---------+-------+
| Field                    | Type                | Null | Key | Default | Extra |
+--------------------------+---------------------+------+-----+---------+-------+
| TABLE_CATALOG            | varchar(512)        | NO   |     |         |       |
| TABLE_SCHEMA             | varchar(64)         | NO   |     |         |       |
| TABLE_NAME               | varchar(64)         | NO   |     |         |       |
| COLUMN_NAME              | varchar(64)         | NO   |     |         |       |
| ORDINAL_POSITION         | bigint(21) unsigned | NO   |     | 0       |       |
| COLUMN_DEFAULT           | longtext            | YES  |     | NULL    |       |
| IS_NULLABLE              | varchar(3)          | NO   |     |         |       |
| DATA_TYPE                | varchar(64)         | NO   |     |         |       |
| CHARACTER_MAXIMUM_LENGTH | bigint(21) unsigned | YES  |     | NULL    |       |
| CHARACTER_OCTET_LENGTH   | bigint(21) unsigned | YES  |     | NULL    |       |
| NUMERIC_PRECISION        | bigint(21) unsigned | YES  |     | NULL    |       |
| NUMERIC_SCALE            | bigint(21) unsigned | YES  |     | NULL    |       |
| DATETIME_PRECISION       | bigint(21) unsigned | YES  |     | NULL    |       |
| CHARACTER_SET_NAME       | varchar(32)         | YES  |     | NULL    |       |
| COLLATION_NAME           | varchar(32)         | YES  |     | NULL    |       |
| COLUMN_TYPE              | longtext            | NO   |     | NULL    |       |
| COLUMN_KEY               | varchar(3)          | NO   |     |         |       |
| EXTRA                    | varchar(27)         | NO   |     |         |       |
| PRIVILEGES               | varchar(80)         | NO   |     |         |       |
| COLUMN_COMMENT           | varchar(1024)       | NO   |     |         |       |
+--------------------------+---------------------+------+-----+---------+-------+
20 rows in set (0.01 sec)
  • bigint(21) を System.UInt64 に変換

の部分を注意して、エンティティクラスを作ります。

[Table("COLUMNS", Schema = "INFORMATION_SCHEMA")]
public class MySqlColumns
{
    public string TABLE_CATALOG { get; set; } = "";
    public string TABLE_SCHEMA { get; set; } = "";
    public string TABLE_NAME { get; set; } = "";
    public string COLUMN_NAME { get; set; } = "";
    public System.UInt64 ORDINAL_POSITION { get; set; }
    public string? COLUMN_DEFAULT { get; set; }
    public string IS_NULLABLE { get; set; } = "";
    public string DATA_TYPE { get; set; } = "";
    public System.UInt64? CHARACTER_MAXIMUM_LENGTH { get; set; }
    public System.UInt64? NUMERIC_PRECISION { get; set; } = null;
    public System.UInt64? NUMERIC_SCALE { get; set; } = null;
    public System.UInt64? DATETIME_PRECISION { get; set; } = null;
    public string? CHARACTER_SET_NAME { get; set; } = null;
    public string? COLLATION_NAME { get; set; } = "";
    public string? COLUMN_TYPE { get; set; } = null;
    public string COLUMN_KEY { get; set; } = "";
    public string EXTRA { get; set; } = "";
    public string PRIVILEGES { get; set; } = "";
    public string COLUMN_COMMENT { get; set; } = "";
}

これで、直接 INFORMATION_SCHEMA.COLUMNS を呼び出すか、 GetSchema(“COLUMNS”) を呼び出して結果を LINQ で検索すればお手軽ですね。と思ったのですが、落とし穴があります。

  • なぜか MySQL の場合は、Table 属性で Schema を切り替えてくれない。
  • GetSchema の戻り値が、DataTable になっている。

まずは、DataTable の中身をチェックします。

Console.WriteLine("MySQL GetSchema");
var context = new RedmineDataContext();
var cn = context.Database.GetDbConnection();
cn!.Open();
var t = cn.GetSchema("columns");

Console.WriteLine("columns.列名");
foreach (DataColumn col in t.Columns)
{
    Console.WriteLine("{0} {1}", col.ColumnName, col.DataType);
}

Console.WriteLine("\ncolumns.rows");
Console.WriteLine("TABLE_NAME   COLUMN_NAME COLUMN_TYPE IS_NULLABLE COLUMN_KEY  EXTRA");

foreach ( DataRow row in t.Rows )
{
    Console.WriteLine("{0}  {1} {2} {3} {4} {5} {6}",
        row["TABLE_NAME"],
        row["COLUMN_NAME"],
        row["COLUMN_TYPE"],
        row["IS_NULLABLE"],
        row["COLUMN_KEY"],
        row["COLUMN_DEFAULT"],
        row["EXTRA"]);
}

columns のテーブル構造は、DataTable#Columns を使っても確認ができます。DataRow にいちいちカラム名を指定するのがアレですが、ひとまず DESC コマンド互換のものが取得できます。

DataTable からエンティティへ変換する拡張を作る

DataTable.Rows からエンティティに変換させる拡張メソッドを作ります。

public static class DataTableExtenstions
{
    /// <summary>
    /// DataTable.Rows を指定した List<T>に変換する
    /// </summary>
    public static List<T> ToList<T>(this DataTable src) where T : new()  
    {
        var items = new List<T>();
        var properties = typeof(T).GetProperties();
        // TODO: Column 属性があれば、探索するカラム名を変更する
        foreach ( DataRow row in src.Rows )
        {
            var item = new T();
            foreach ( var pi in properties )
            {
                var value = row[pi.Name];
                if ( value == System.DBNull.Value )
                {
                    pi.SetValue(item, null);
                } 
                else
                {
                    pi.SetValue(item, row[pi.Name]);
                }
            }
            items.Add(item);
        }
        return items;
    }
}

本当は Column 属性を調べないとダメなのですが、これは後日。ひとまず、詰め込みをできる ToList メソッドを作ったので、これを使って LINQ で使えるようにします。

var items = cn.GetSchema("columns").ToList<MySqlColumns>();
Console.WriteLine("\nMySqlColumns");
Console.WriteLine("TABLE_NAME   COLUMN_NAME COLUMN_TYPE IS_NULLABLE COLUMN_KEY  EXTRA");
items = items.OrderBy( t => t.TABLE_NAME).ThenBy( t => t.ORDINAL_POSITION ).ToList();

foreach (var it in items)
{
    Console.WriteLine($"{it.TABLE_NAME}  {it.COLUMN_NAME} {it.COLUMN_TYPE} {it.IS_NULLABLE} {it.COLUMN_KEY} {it.COLUMN_DEFAULT} {it.EXTRA}");
}

GetSchema の戻り値は、テーブル名等でソートされていないので、ソートしておきます。

結果は DESC コマンドと似た感じで取得できます。多分、DESC コマンドのほうは整形しているのでしょう。

これを使って T4 で変換してもよいし、別途コマンドツールを使って Models 群を作ります。が、ここで再び dotnet ef dbcontext scaffold に立ち戻って、もうちょっとスイッチを使ったら使いやすいように出ないか?(特に命名規約の部分)を調べ直します。

dotnet ef dbcontext scaffold を使う

使い方が下記のページに書いてあります。

リバース エンジニアリング – EF Core | Microsoft Docs https://docs.microsoft.com/ja-jp/ef/core/managing-schemas/scaffolding?tabs=dotnet-core-cli

書いてはあるのですが、実に使いにくいのです。dotnet ef コマンド自体がコードファーストのためにツールという感じがするので、あまり既存のデータベースから細かく取得することができません。まあ、それでもおおざっぱに取れればいいので、以下のコマンドを powershell で実行します。

コマンドを実行するときは、

  • Microsoft.EntityFrameworkCore.Design を含んだプロジェクトであること

が必須です。もともと webapi や mvc プロジェクトにエンティティクラスを追加するコマンドなので、このような形になっています。

dotnet ef dbcontext scaffold `
 "Server=localhost;User=redmine;Password=redmine;Database=redmine" `
 "MySql.EntityFrameworkCore" `
 --data-annotations `
 -o Models `
 --table projects `
 --force
  • `(バッククォート)は継続文字です
  • 接続文字列をそのまま記述します(これが結構面倒)
  • 接続するためのアセンブリを指定。MySQL の場合は、MySql.EntityFrameworkCore
  • –data-annotations C# と MySQL のカラム名の変換を属性で指定する
  • Models フォルダーに出力(これがないと、カレントフォルダーにばらまかれる)
  • –table スイッチで出力するテーブルを指定。指定しないと全テーブルが対象になる。
  • –force 上書き用のスイッチ

–data-annotations が肝で、デフォルトでは、DbContext の OnModelCreating にばらばらと記述されるのですが、これをエンティティクラスの属性に切り替えます。

これがうまくいくと Models/Project.cs が出力されます。この部分は、ASP.NET MVC 本とか Blazor本とかで必ず出てくるのですが、dotnet ef コマンドの扱いが難しくて、躓きやすいところですよね。なんとか乗り越えてください。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

#nullable disable

namespace getschema.Models
{
    [Table("projects")]
    [Index(nameof(Lft), Name = "index_projects_on_lft")]
    [Index(nameof(Rgt), Name = "index_projects_on_rgt")]
    public partial class Project
    {
        [Key]
        [Column("id", TypeName = "int(11)")]
        public int Id { get; set; }
        [Required]
        [Column("name")]
        [StringLength(255)]
        public string Name { get; set; }
        [Column("description")]
        public string Description { get; set; }
        [Column("homepage")]
        [StringLength(255)]
        public string Homepage { get; set; }
        [Required]
        [Column("is_public")]
        public bool? IsPublic { get; set; }
        [Column("parent_id", TypeName = "int(11)")]
        public int? ParentId { get; set; }
        [Column("identifier")]
        [StringLength(255)]
        public string Identifier { get; set; }
        [Column("status", TypeName = "int(11)")]
        public int Status { get; set; }
        [Column("lft", TypeName = "int(11)")]
        public int? Lft { get; set; }
        [Column("rgt", TypeName = "int(11)")]
        public int? Rgt { get; set; }
        [Column("inherit_members")]
        public bool InheritMembers { get; set; }
        [Column("default_version_id", TypeName = "int(11)")]
        public int? DefaultVersionId { get; set; }
        [Column("default_assigned_to_id", TypeName = "int(11)")]
        public int? DefaultAssignedToId { get; set; }
    }
}

Visual Studio 2022 ではデフォルトで nullable enable となっているので、警告をおさえるために「#nullable disable」が追加になっています。

すべてのカラム(プロパティ)にColumn属性がついているので、MySQLとC#のマッピングが出来た状態です。

これでおおむねはいけますね。

timestamp のカラムが消されているので注意

スキャフォードしたときに警告が出ているのですが、先の Project クラスを見ると、created_on と updated_on に相当するものがありません。実は、この2つのカラムの型は「timestamp」となっているので、C# の型に自動でコンバートしないため、削除されてしまっているのです。

仕方がないので、

  • timestamp を DatTime に変換

したプロパティを手作業で追加します。

MySQL の場合は、時間絡みの型がいくつかってそれをC#にどのようにコンバートするのか、日付の 0000/00/00 はどう扱うのか、が問題になるので注意しなければいけないところです。

でもって、やっとこさエンティティクラスの出力の目途が立ってので、これ gRPC で利用します。これは後日。

Pomelo.EntityFrameworkCore.MySql を使う

追記で、

スキャフォードを使うときに、Pomelo.EntityFrameworkCore.MySql を使うと timestamp の問題が解決できます。

PomeloFoundation/Pomelo.EntityFrameworkCore.MySql: Entity Framework Core provider for MySQL and MariaDB built on top of MySqlConnector https://github.com/PomeloFoundation/Pomelo.EntityFrameworkCore.MySql

dotnet ef dbcontext scaffold `
 "Server=localhost;User=redmine;Password=redmine;Database=redmine" `
 "Pomelo.EntityFrameworkCore.MySql" `
 --data-annotations `
 -o Models `
 --table projects `
 --force

この状態で project.cs が以下のように出力されます。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace getschema.Models
{
    [Table("projects")]
    [Index(nameof(Lft), Name = "index_projects_on_lft")]
    [Index(nameof(Rgt), Name = "index_projects_on_rgt")]
    [MySqlCharSet("utf8")]
    [MySqlCollation("utf8_general_ci")]
    public partial class Project
    {
        [Key]
        [Column("id", TypeName = "int(11)")]
        public int Id { get; set; }
        [Required]
        [Column("name")]
        [StringLength(255)]
        public string Name { get; set; }
        [Column("description", TypeName = "text")]
        public string Description { get; set; }
        [Column("homepage")]
        [StringLength(255)]
        public string Homepage { get; set; }
        [Required]
        [Column("is_public")]
        public bool? IsPublic { get; set; }
        [Column("parent_id", TypeName = "int(11)")]
        public int? ParentId { get; set; }
        [Column("created_on", TypeName = "timestamp")]
        public DateTime? CreatedOn { get; set; }
        [Column("updated_on", TypeName = "timestamp")]
        public DateTime? UpdatedOn { get; set; }
        [Column("identifier")]
        [StringLength(255)]
        public string Identifier { get; set; }
        [Column("status", TypeName = "int(11)")]
        public int Status { get; set; }
        [Column("lft", TypeName = "int(11)")]
        public int? Lft { get; set; }
        [Column("rgt", TypeName = "int(11)")]
        public int? Rgt { get; set; }
        [Column("inherit_members")]
        public bool InheritMembers { get; set; }
        [Column("default_version_id", TypeName = "int(11)")]
        public int? DefaultVersionId { get; set; }
        [Column("default_assigned_to_id", TypeName = "int(11)")]
        public int? DefaultAssignedToId { get; set; }
    }
}
  • #nullable disable は手作業で加える。

カテゴリー: 開発 | 既存のMySQLからEF CoreのEntityを作成する はコメントを受け付けていません

ACCESS を .NET Core の EF Coreでアクセスする

いまさら ACCESS を扱うにしても ADO.NET の DataSet や DataTable を使いたくない場合があります。素直に .NET Framework のほうを使ってもいいのですが、.NET5(多分 .NET6でも大丈夫)で ACCESS のデータベースファイル(*.mdb)を扱います。

EntityFrameworkCore.Jet: Entity Framework Core provider for Access database を使います。

ちょっとだけ難点があって、EF Core の 3.x(最新は EF Core 5.x)だけ対応しています。なので EF Core のバージョンをひとつ前に戻さないといけないのですが、DataSet を使ってちまちまとカラム名を入力するよりは楽です。現在のプロジェクトだと以下のようになります。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net5.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
  </PropertyGroup>
	<ItemGroup>
		<PackageReference Include="EntityFrameworkCore.Jet.OleDb" Version="3.1.0" />
		<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.20" />
	</ItemGroup>
</Project>

これは WPF のプロジェクトなのですが、webapi も作れます。

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net5.0-windows</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
	<PackageReference Include="EntityFrameworkCore.Jet.OleDb" Version="3.1.0" />
	<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.20" />
  </ItemGroup>
</Project>

webapi の場合、.NET5 で作ると TargetFramework が「net5.0」となっていますが「net5.0-windows」のように書き換えます。OLEDB 関係が windows 上のみの動作のため EntityFrameworkCore.Jet.OleDb の制限です。ちなみに net5.0 のままでも、警告はでますが EntityFrameworkCore.Jet.OleDb のクラスやメソッドは利用できます。

Microsoft.ACE.OLEDB.12.0 をインストール

実は Windows 10 が素の状態だと、ACCESS 用の OLE ドライバーが入ってません。あと、最新版の ACCESS の場合、Microsoft.ACE.OLEDB.16.0 だと、EntityFrameworkCore.Jet.OleDb がうまく動かなかったので 12.0 のほうを入れます。

Download Microsoft Access データベース エンジン 2010 再頒布可能コンポーネント from Official Microsoft Download Center
https://www.microsoft.com/ja-jp/download/details.aspx?id=13255

うちの Windows 10 の環境では 32bit 版の Office 2019 が入っていて、あとから Microsoft.ACE.OLEDB.16.0 の再配布コンポーネントがなぜか 32/64 のどちらも入れることができません。16.0 のほうは微妙にややこしいことになるかも。Microsoft.ACE.OLEDB.16.0のインストールについて

DbContext を作る

データベースにアクセスするための DbContext を作ります。これは EF Core の DbContext の作り方と同じです。OnModelCreating をオーバーライドして、HasNoKey を設定しているのは ACCESS データから読み取りしかしないからです。元テーブル(予約テーブルとかには Key が入っていません)。

public class AccessDbContext : DbContext
{
    public AccessDbContext()
    {

    }
    public AccessDbContext(DbContextOptions<AccessDbContext> options) : base(options)
    {

    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        OleDbConnectionStringBuilder builder = new OleDbConnectionStringBuilder();
        builder.Provider = "Microsoft.ACE.OLEDB.12.0";
        builder.DataSource = @"ACCESSファイル.mdb";
        string cnstr = builder.ConnectionString;
        optionsBuilder.UseJetOleDb(cnstr);
    }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<T_予約>().HasNoKey();
        modelBuilder.Entity<T_予約sub>().HasNoKey();
        modelBuilder.Entity<T_顧客>().HasNoKey();
...

    }
    public DbSet<T_予約> T_予約 { get; set; }
    public DbSet<T_予約sub> T_予約sub { get; set; }
    public DbSet<T_顧客> T_顧客 { get; set; }
...
}

対応するテーブルの作り方もコードファーストのデータベース作りと同じになります。やたらに Nullable が多いのと、カラム名が日本語になっているのが元データが ACCESS のためです。元の ACCESS では VBA でガシガシと組んでいて大変そうです。

public partial class T_予約
{
    public int KID { get; set; }
    public Nullable<int> TID { get; set; }
    public Nullable<int> 予約者 { get; set; }
    public Nullable<int> TaID { get; set; }
    public Nullable<System.DateTime> 日付 { get; set; }
    public Nullable<System.DateTime> 記録日 { get; set; }
    public Nullable<System.DateTime> 契約日 { get; set; }
    public Nullable<int> 予約 { get; set; }
...
}

ACCESS のカラム名は結構自由に作れるので、郵便番号の記号「〒」も使えます。ただし、C# の場合は「〒」をプロパティ名としては使えないので Column 属性で別名をあてておきます。

public partial class T_顧客Sub
{
    public int TIDa { get; set; }
    public Nullable<int> TID { get; set; }
    public string 部署名 { get; set; }
    public string TEL { get; set; }
    public string FAX { get; set; }
    public string 携帯 { get; set; }
    [Column("〒")]
    public string ZIP { get; set; }
    public string 住所1 { get; set; }
    public string 住所2 { get; set; }
...
}

ここまでできあがると、通常の EF Core と同じように ACCESS にアクセスができます。あまり無茶をすると ACCESS ファイルが壊れそうな気がするのですが、まあ SELECT だけならば大丈夫でしょう。

webapi のコントローラーで使う

LINQ 使って記述ができるので、非常に楽です。

/// <summary>
/// 日付を指定して取得する
/// </summary>
/// <param name="date"></param>
/// <returns></returns>
[HttpGet("date/{date}")]
public IEnumerable<T_予約> Get(string date)
{
    DateTime dt = DateTime.Parse(date);
    var items =  _context.T_予約
        .Where( t => t.日付 == dt.Date )
        .ToList();
    foreach ( var item in items )
    {
        item.予約Sub = _context.T_予約sub.Where(t => t.KID == item.KID)
            .OrderBy(t => t.Sort)
            .ToList();
        item.顧客 = _context.T_顧客.FirstOrDefault(t => t.TID == item.TID);
        item.顧客Sub = _context.T_顧客Sub.FirstOrDefault(t => t.TIDa == item.TIDa);
        item.会議室Item = _context.T_ROOM.FirstOrDefault(t => t.HID == item.会議室);
    }
    return items;
}

付属するデータは、LINQ の場合は Include を使って取ってきてもよいのですが、外部キーの記述が結構面倒(標準にあっていないとうまくいかないことが多い)ので、あとから手作業でとってきています。多少検索スピードは落ちますが、ツール的にはこれで十分でしょう。まじめに作るときは SQL Server 等にデータを移行&カラム名を連携しやすいように直します。

ACCESSファイルのロック

ACCESSの場合、テーブル単位ロックがかかっています。例えば、上記の場合、ACCESS 本体で「予約」テーブルを開いていると(デザインを開くも含む)と、EF Core 側で「予約」テーブルを開くことができなくてエラーになります。実際には、ロック解除待ちのタイムアウトになります。

なので、同時利用をしていると変なことになりそうなのですが、そこは運用次第ということで。ひとまず、ACCESS から SQL Server への移行ツールを作るときに EntityFrameworkCore.Jet が便利です。

カテゴリー: 開発 | ACCESS を .NET Core の EF Coreでアクセスする はコメントを受け付けていません

.NET CoreでNULL付きのデータをSQL Serverに大量投入する

2年ほど前、LINQ を使って大量データを INSERT すると遅すぎるので、LINQ の INSERT を SqlBulkCopy にするとどれだけ早くなるのか? | Moonmile Solutions Blog で、SqlBulkCopy を試しました。この記事の例では、6秒から 0.06 秒になって 100 倍になっています。実際は、仕事で使うデータ移行分があって、2万件ほどが1時間ぐらいかかっていたのが、数秒で終わるように効率化されています。

このツール自体は .NET Framework の EF6 で書いているのですが、現在 .NET5 の EF Core で書き換え中です。いままで、EF6 を使って Visual Studio 上で Model クラスを作っていたのですが、EF Core のほうでコードファースト的にテテーブル用に対応するクラスを作っています。コードファーストとはいえ、実際にデータベースに反映してはいません。しかし、いちいち Visual Studio 上でデータベース内のテーブルとの同期をとらなくてよいので楽です。

まあ、それはそれで、手作業でテーブルを書き換えてはマイグレーションするわけですが。

さて、EF Core の INSERT も EF6 の INSERT と同じようにかなり遅いです。EF6 の場合は、

  • AutoDetectChangesEnabled
  • ValidateOnSaveEnabled

を false にすることで、INSERT の高速化がそこそこできる(5倍ぐらい早くなる)のですが、数万件のデータを投入しようとする結構かかります。さらに、手元のデータでは EF6 よりも EF Core の INSERT が 5倍ぐらい遅いので、ちょっと大きめのデータだと INSERT だけでは実用的に無理ということになります。

そこで、再び SqlBulkCopy の出番なのです。以前書いていた拡張メソッド AsDataTable だと null 許容型が通らないので少し書き換えます。

public static class DataTableExtenstions
{
    public static DataTable AsDataTable<T>(this DbSet<T> src) where T : class
    {
        return DataTableExtenstions.AsDataTable(src.Local);
    }
    public static DataTable AsDataTable<T>(this IEnumerable<T> src) where T : class
    {
        var properties = typeof(T).GetProperties();
        var dest = new DataTable();
        // テーブルレイアウトの作成
        foreach (var prop in properties)
        {
            DataColumn dc = new DataColumn();
            dc.ColumnName = prop.Name;
            if (prop.PropertyType.IsGenericType && 
                prop.PropertyType.GetGenericTypeDefinition() == typeof(Nullable<>))
            {
                dc.DataType = Nullable.GetUnderlyingType(prop.PropertyType);
                dc.AllowDBNull = true;
            } else
            {
                dc.DataType = prop.PropertyType;
            }
            dest.Columns.Add(dc);
        }
        // 値の投げ込み
        foreach (var item in src)
        {
            var row = dest.NewRow();
            foreach (var prop in properties)
            {
                var itemValue = prop.GetValue(item, new object[] { });
                row[prop.Name] = itemValue ?? System.DBNull.Value;
            }
            dest.Rows.Add(row);
        }
        return dest;
    }
}

データを投入するときに null から System.DBNull.Value にしておきます。

private void blukSave<T>(string tablename, IEnumerable<T> items, bool keepid = true ) where T : class
{
    var cnstr = toEnt.Database.GetDbConnection().ConnectionString;
    SqlBulkCopy bc;
    if (keepid == true)
    {
        bc = new SqlBulkCopy(cnstr, SqlBulkCopyOptions.KeepIdentity);
    }
    else
    {
        bc = new SqlBulkCopy(cnstr);
    }
    bc.DestinationTableName = tablename;
    var dt = items.AsDataTable();
    bc.WriteToServer(dt);
}

確か、以前の BulkCopy は ID をインクリメントしなかったような気がするのですが、現在の SqlBulkCopy は ID を挿入時にインクリメントしてしまいます。大量データを投入するときは、ID はあらかじめ振ってあることが多い(他のデータから移行するため)ので、INSERT 時に ID の値が変わらないようにします。

オプションで SqlBulkCopyOptions.KeepIdentity をつけておきます。

実際の使い方はこんな感じ。予約テーブルは実は ACCESS から移行するデータなので大量に Nullable が入っています。いったん List にため込んでから、BlukCopy を行うので一時的にため込まれる List のメモリ量が心配ですが、まあ、大丈夫でしょう。最初の DropTable 関数は、内部で TRUNCATE TABLE を呼び出しています。

public bool To予約()
{
    DropTable("予約");
    var lst = new List<予約>();
    foreach (var it in fromEnt.T_予約)
    {
        var t = new 予約();
        t.ID = it.KID;
        t.顧客ID = it.TID.Value;
        t.顧客SUBID = it.TIDa ?? 0;
        t.予約者 = it.予約者;
        ...
        t.UpdateAt = DateTime.Now;

        lst.Add(t);
    }
    this.blukSave("予約", lst);
    return true;
}

この状態で、2万件のデータを投入すると数秒で終わります。EF Core の LINQ の INSERT を使うと、2時間ぐらいかかるので、これは実用的とは言えません。何が遅いのがいまいち不明ですね。。。

余談ですが、.NET Core の DbContext を使ったときに、AutoDetectChangesEnabled や ValidateOnSaveEnabled がありません。代わりに、OnConfiguring をオーバーライドして UseQueryTrackingBehavior を使います。トラッキングをしないようにすると、多少は早くなる(2,3倍ぐらい)のですが、SqlBulkCopy の 100倍には遠く及びません。

public class KaigiDbContext : DbContext
{
    public KaigiDbContext()
    {

    }
    public KaigiDbContext(DbContextOptions<AccessDbContext> options) : base(options)
    {
    }
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        string cnstr = "data source=.;initial catalog=会議室;integrated security=True";
        optionsBuilder.UseSqlServer(cnstr);
        optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
    }
    public DbSet<予約> 予約 { get; set; }
...
}

カテゴリー: 開発 | .NET CoreでNULL付きのデータをSQL Serverに大量投入する はコメントを受け付けていません

COCOA のビーコンを Windows 10 で受け取る

かねてから、接触確認API(Exposure Notifications API)は自作しないとあかんな、と思っていたのでおもむろに自作してみることにします。要は、接触確認アプリのテストがしにくい(EN API が有効な保健省アカウントしかできない)ので、一般サイドから見ると「きちんと動いているかどうかわからない」のが問題ですね。これは、COCOA 自体から EN API を触るときも同様で、去年の6月当初から検証しにくい環境であることができになっています。

で、EN API については内部的な細かい動作はさておき、仕様は Apple/Google の共同文書ということで公開されています。

Apple/Google の EN API インターフェースはさておき、もっと物理層に近いところの BLE 通信の部分と電文の暗号化は公開されているので、これを使うことでビーコンの発信と受信が可能です。

Windows 10 で BLE を受け取る

ビーコンというか、Bluetooth Low Energy の受信は、Apple の iBeacon が発表された 5年前ぐらいに非常に流行りました。なので、サンプルコード回りを探すと 4,5 年間前のものがたくさん出てきて、最近1,2年のものが引っかかりませんが大丈夫です。OS 等の環境は変わっているのですが、おおむね動きます。この「おおむね動く」というのが落とし穴で、OS が新しくなったり開発環境が変わったり(Android が Java から Kotlin へ iOS が Swift へとか)して、元のサンプルコードがそのままでうまく動かなかったりします。

Windows 10 の場合、実は Windows Runtime(UWP)のほうで BLE を自由に扱えるのですが、Windows 10 側で扱える方法はありません。なので、なので、Windows 10(いまから Windows 11 になるけど)の場合、ちょっとだけ工夫が必要です。

以前の .NET Framework の場合、UWP での Runtime を Windows 10 側で動かすときにちょっと工夫が必要だったのですが、.NET 5 以降は以下のように *.csproj に書けば ok です。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
	  <TargetFramework>net5.0-windows10.0.19041.0</TargetFramework>
  </PropertyGroup>
</Project>

.NET 5 でプロジェクトを作成(.NET 6 も同じです)すると、*.csproj の中身が Microsoft.NET.Sdk を使ったものに切り替わります。ここの TargetFramework のところは通常は「net5.0」になっているのですが「net5.0-windows10.0.19041.0」のように、UWP の Win RT を含めたものにします。後ろのバージョンは、Windows 10 が動作しているバージョンにあわせます。

デスクトップ アプリで Windows ランタイム API を呼び出す – Windows apps | Microsoft Docs

これで、BLE を扱うための、BluetoothLEAdvertisementWatcher クラスが使えるようになります。

受信する BLE の種類

参考先:【BLE】GAP・GATTについて調べてみた – Qiita

あらためて、作ってみて自分の中でごちゃごちゃしていていたので、メモ的に整理しておきます。

  • セントラル:BLE を受信する側
  • ペリフェラル:BLE を送信する側
  • GAP(Generic Access Profile):ペリフェラルから定期的に送信される電文、ブロードキャスト
  • GATT、キャラクタリスティックス(characteristics):セントラル側からコネクトして、改めてペリフェラルから電文を得る(相互通信も)

以前、GATT がよくわからなかったのですが、コネクトするやつだったのですね。

なので、巷にあるサンプルはたいていは、GATT を使ってセントラルからコネクトして相互通信するサンプルがほとんどです。GAP で送る最初のデータは、アドバタイジングデータというのですが、これが 31 バイトで制限されています。最初のデータは検知用のブロードキャストなので、セントラルの ID か、iBeacon のようになんらかの UUID をのせて発信させておくという手法です。

で、EN API は、BLE で GAP なブロードキャストを定期的(2秒から5秒間隔ぐらい)で流し続けています。BLE が低電力なのは、通信が 200 msec 間隔のような短い間隔ではなくて、間欠的にデータを発信するからです。単純に考えれば1/10から1/50ぐらいの電力量で済むので、スマホの電源をあまり消費しない…はずなのですが。たぶん、発信のための電力よりも、Bluetooth の受信のほうに電池を使っているような気がします。

とりあえず、Windows 10 で COCOA なビーコンを受信する場合は、

  • Windows 10 がセントラルになる
  • ブロードキャスト(GAP)で発信されているデータ(アドバタイジングデータ)を受信する

という2つがあれば十分です。

BLE を受信する

ひたすら受信するだけなので、BluetoothLEAdvertisementWatcher クラスを使います。

using Windows.Devices.Bluetooth.Advertisement;
static BluetoothLEAdvertisementWatcher watcher;

static void Main(string[] args)
{
    Console.WriteLine("CacaoBeacon Reciever");
    // スキャンモードを設定
    watcher = new BluetoothLEAdvertisementWatcher()
    {
        ScanningMode = BluetoothLEScanningMode.Passive
    };
    // スキャンしたときのコールバックを設定
    watcher.Received += Watcher_Received;
    // スキャン開始
    watcher.Start();
    // キーが押されるまで待つ
    Console.WriteLine("Press any key to continue");
    Console.ReadLine();
}

これは、動作確認のためコンソールアプリケーションで作っていますが、.NET 5 で作れば、WPF でも同じように作れます。多分、Windows フォームも同じです。

BLE のデータを検出するたびに Received イベントが呼び出されます。ひとつにつき1回呼び出されるので、まわりにビーコン(接触確認アプリ等)がたくさんあると、イベント先が溢れます。本格的に作るときは、受信データの処理などは別スレッドにする必要があります。

BLE のデータを処理する(アドバタイジングデータ)

受信したデータは、args.Advertisement の中に入っています。この Advertisement な中にいろいろなプロパティが設定してあって、ペリフェラル(接触確認アプリ)が送信してくるビーコンの中身を分解してくれます。

private static void Watcher_Received(
    BluetoothLEAdvertisementWatcher sender,
    BluetoothLEAdvertisementReceivedEventArgs args)
{

    var uuids = args.Advertisement.ServiceUuids;
    var mac = string.Join(":",
                BitConverter.GetBytes(args.BluetoothAddress).Reverse()
                .Select(b => b.ToString("X2"))).Substring(6);
    var name = args.Advertisement.LocalName;

    if (uuids.Count == 0) return;
    if (uuids.FirstOrDefault(t => t.ToString() == "0000fd6f-0000-1000-8000-00805f9b34fb") == Guid.Empty) return;

    // RPI を取得
    byte[] rpi = null;

    foreach (var it in args.Advertisement.DataSections)
    {
        if ( it.DataType == 0x16 && it.Data.Length >= 2 + 16)
        {
            byte[] data = new byte[it.Data.Length];
            DataReader.FromBuffer(it.Data).ReadBytes(data);
            if ( data[0] == 0x6f && data[1] == 0xfd)
            {
                rpi = data[2..18];
                cbreceiver.Recv(rpi, DateTime.Now, args.RawSignalStrengthInDBm, args.BluetoothAddress);
            }
        }
    }

実は、Advertisement(BluetoothLEAdvertisementクラス)は、さまざまなプロパティやコレクションを持っていますが、データの中身としては 31 バイトしかないので、たいしたことができるわけではありません。ちょうど C言語の Union のように構造体が相乗りになっている(データ構造は排他的になる)のを想像してください。

なので、以下のように DataSections と ManufacturerData を同時に取得して表示させていますが、これが同時に取れることはありません。データ量が 31 バイトしかないので、どちらかしか取れません。たいていのサンプルは ManufacturerData だけを扱います。ここで作成する COCOA からの受信は DataSections だけで十分です。

var dataSections = args.Advertisement.DataSections;
var manufactures = args.Advertisement.ManufacturerData;
Console.WriteLine($"dataSections count:{dataSections.Count}");
foreach (var it in dataSections)
{
    Console.WriteLine($" type: {it.DataType.ToString("X2")} size: {it.Data.Length} data: {toHEX(it.Data)}");
    byte[] data = new byte[it.Data.Length];
    DataReader.FromBuffer(it.Data).ReadBytes(data);
}
Console.WriteLine($"manufactures count:{manufactures.Count}");
foreach (var it in manufactures)
{
    Console.WriteLine($" size: {it.Data.Length} data: {toHEX(it.Data)}");
}

データは byte 配列になるので、DataReader とか BitConverter を活用します。

電文の中身を見るのに AD Type をみるのですが、この一覧は Advertising Data ・ BLEDocs を見てください。

EN API の Bluetooth 版を見ると、

  • 0x01: Flag
  • 0x03: Complete 16-bit Service UUID
  • 0x16: Service Data

の3つだけ理解できれば ok です。0x16 は DataSections コレクションになっています。コレクションとはいえ、31 バイトしかないので、基本はひとつしかないです。この中に

  • Exposure Notification Service の 16 bit UUID
  • RPI(Rooling Proximity Identifier)が 16バイト
  • メタデータが4バイト

が入っています。

一応先頭の 2バイト(16ビット)の UUID をチェックして、RPI の 16バイトを取得します。

後日解説しますが、0xFD6F の UUID は、Windows 10, Android, M5Stack では受信できるのですが、iPhone では受信できません。iOS の場合 OS 内部で、EN API の 0xFD6F だけ塞がれています。ちなみにここの UUID を変えれば、iOS でも受信ができます(通常のビーコンは通るということ)。

実際に受信する

動作するコードは https://github.com/openCACAO/CacaoBeacon/blob/main/src/consolerecv/Program.cs にあります。内部で、受信用の CacaoBeacon クラスを使っているので、ひとつ上の *.sln ごと git clone するとよいです。

実際に Windows 10 で動かすとこんな感じになります。

ビーコン発信する機器の MAC アドレスは 10 分程度で切り替わるランダム値になるので、継続監視はあまり意味はありません。RPI の値も 10 分間隔で切り替わります。

10分の間で、接触確認アプリを Windows 10 から遠ざけたり近づけたりすると、RSSI(電波強度)が変わります。consolerecv では連続受信した RPI を集約させて、開始時刻と終了時刻を保存するようにしてあります。

このツールをノートPCに入れて、駅前のスタバに行けば、COCOA のチェックができますね。まあ、できたところで何ができるかというとこれだけでは何もできないのですが。

それに、いちいちノートPCを見てコンソールで確認するのも大変です。WPF アプリに移植するのは後日やるとして、これを Android で動かせるようにします。

カテゴリー: 開発 | COCOA のビーコンを Windows 10 で受け取る はコメントを受け付けていません

MAUI で ListView を使って撃沈してみるテスト(Windowsのほうは大丈夫)

MAUI を始める、というか調べることにしたので、なにはともあれ Web API を呼び出して、なにかリスト表示をしようと作ってみる。だが、作ってみる途中でどうも動かないので、備忘録に記録しておく。

実は MAUI のドキュメント自体は極めてすくなくて、.NET Multi-Platform App UI documentation – .NET MAUI | Microsoft Docs 程度しかない。なんというか、いまのところ何もない状態に近いので、肝心の画面をどう作っていくのか?に関しては全くわからない。でも、まったくわからないながらも、もともと Xamarin.Forms からの移行になるのだから、Xamarin.Forms の XAML 形式をコピペすればいけるだろう。ということで、最初は Visual Studio 2019 を使って Xamarin.Forms で作ってみる。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="XamarinWebApi.MainPage">

    <ScrollView>
        <Grid RowSpacing="25">
            <Grid.RowDefinitions>
                <RowDefinition Height="auto" />
                <RowDefinition Height="auto" />
                <RowDefinition Height="*" />
            </Grid.RowDefinitions>

            <Label Text="web api test"
                Grid.Row="0"
                HorizontalOptions="CenterAndExpand" />

            <Button Text="call group"
                    Grid.Row="1"
                    HorizontalOptions="CenterAndExpand"
                    Clicked="clickGroup"/>

            <ListView x:Name="lv" Grid.Row="2">
                <ListView.ItemTemplate>
                    <DataTemplate>
                        <TextCell Text="{Binding Name}" />
                    </DataTemplate>
                </ListView.ItemTemplate>
            </ListView>
        </Grid>
    </ScrollView>
</ContentPage>

たぶん、こんな風に ListView を使ったはず。ボタンをクリックしたときに Web API を呼び出すのはこんな感じ。

private async void clickGroup(object sender, EventArgs e)
{
    var cl = new HttpClient();
    var url = new Uri("http://192.168.1.28:5000/api/areagroup");
    var json = await cl.GetStringAsync(url);
    var js = new JsonSerializer();
    var items = JsonConvert.DeserializeObject<List<AreaGroup>>(json);
    this.lv.ItemsSource = items;
}

Web API 自体はローカルな dotnet で動かしているので、IP アドレスはローカルコンピュータのものになっている。Android エミュレータから呼び出すことになるので localhost ではなく、IP アドレスになっている。

さて、実はこれを動かすと次のようなエラーになる。

System.Net.WebException: 'Cleartext HTTP traffic to 192.168.1.28 not permitted'

とあるバージョンから Android は平文(HTTP)を通すことができないのである。

ああ、そうそう、ということで AndroidManifest.xml ファイルに android:usesCleartextTraffic=”true” の記述をいれることになる。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
	<uses-sdk android:minSdkVersion="30" android:targetSdkVersion="30" />
	<application 
		android:allowBackup="true" 
		android:icon="@mipmap/appicon" 
		android:roundIcon="@mipmap/appicon_round" 
		android:supportsRtl="true"
		android:usesCleartextTraffic="true"
		>
	</application>
	<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
	<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

これでデータが取得できるようになる。

デバッグ用の場合は平文の HTTP を通すのが手っ取り早いのだけど、一応 HTTPS のほうも通しておきたいと思って、書き換えてためしてみると。

var url = new Uri("http://192.168.1.28:5000/api/areagroup");
今度は証明書のエラーがでる。これは、dotnet コマンドが提供している証明書が通せないエラーなので、当然といえば当然なのだけど。ローカルなコンピュータに証明書を入れることはできないので、無視してほしい。

ということで、次のようにコードを書き換える。

private async void clickGroup(object sender, EventArgs e)
{
    var httpHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (o, cert, chain, errors) => true };
    var cl = new HttpClient(httpHandler);
    var url = new Uri("https://192.168.1.28:5001/api/areagroup");
    var json = await cl.GetStringAsync(url);
    var js = new JsonSerializer();
    var items = JsonConvert.DeserializeObject<List<AreaGroup>>(json);
    this.lv.ItemsSource = items;
}

HttpClient のインスタンスを作成するときに、証明書のエラーをスルーするようにオプションを入れる。これも以前みたようなことがあった(また結構さがしたけど)。

これで無事通るようになる。

さて、Xamarin.Forms で動くようになったので、これを Visual Studio 2022 の MAUI のほうにコピーする。

XAML のほうは、そのままコピーで ok.

コードのほうはこんな感じ。

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.Maui.Controls;
using System.Net.Http;
using System.Net.Http.Json;
using System.Collections.Generic;

namespace HelloWebApi
{
    public partial class MainPage : ContentPage
    {
        public MainPage()
		{
			InitializeComponent();

		}
        private async void clickGroup(object sender, EventArgs e)
        {
            var httpHandler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (o, cert, chain, errors) => true };
            var cl = new HttpClient(httpHandler);
            var url = new Uri("https://192.168.1.28:5001/api/areagroup");
            var items  = await cl.GetFromJsonAsync<List<AreaGroup>>(url);
            this.lv.ItemsSource = items;
	    }
	}

JSON のパースは GetFromJsonAsync が使えるようになったので楽ですね。とか思ったら、ServerCertificateCustomValidationCallback なところでエラーが発生します。

どうやら、.NET 6(かな?)のほうでは現状 ServerCertificateCustomValidationCallback がサポートされていない模様。どうも次の preview のバージョンでこれは入る模様です。

仕方がないので、再び AndroidManifest.xml に android:usesCleartextTraffic=”true” を追加して HTTP で通すことにする。

これを Android エミュレータで動かすと。

どうやら、HttpClient でデータは返ってきているのだが、ListView が動いていない模様。

ちなみに、Windowsアプリのほう(実質 UWP)は動いてます。

懐かしの UWP な感じがするのだけど、これ、レイアウトが MainPage.xaml で完全に共有になっているので、マージンの調節とかが大変そう。Android と iOS 間で十分面倒なのだけど。

ひとまず、ここまで動いたという記録。

カテゴリー: 開発 | MAUI で ListView を使って撃沈してみるテスト(Windowsのほうは大丈夫) はコメントを受け付けていません

Xamarin.Formsでログをファイル出力する(Android編)

Xamarin.Forms で System.Diagnostics.Trace を使ってログファイルに出力する方法は、iOS版と同じなので省略、、、したいところですが、実は落とし穴があります。

Androidでログファイルは何処に出力されるのか?

Xamarin.Forms の共通プロジェクトで、Appクラスのコンストラクタ(App.xaml.cs)に以下のように書いておくとiOSでもAndroidでも統一的に Trace の結果をファイルに出力することができます。

var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var filename = Path.Combine(dir, $"log-{DateTime.Now.ToString("yyyyMMdd-HHmm")}.txt");
var tw = System.IO.File.OpenWrite(filename);
var tr1 = new TextWriterTraceListener(tw);
System.Diagnostics.Trace.AutoFlush = true;
System.Diagnostics.Trace.Listeners.Add(tr1);

ここで、Environment.SpecialFolder.MyDocumentsで取得できる場所は、iOSのほうは「ファイル/アプリ名」の中になるのですが、Androidの場合にはアプリ自身のfilesのフォルダーになっています。このフォルダはアプリ自身しか見えなくて、テストをしたときにログファイルを手軽に見ることができません。ログファイルを閲覧する作るとなると結構面倒です。

Xamarin.Droid ではどうするのか?

先にAndroid固有のプロジェクト(Xamarin.Droid)のほうを解決しておきます。
iOSと同じ様に、Androidにも「ファイル」というアイコンがあります。この「ファイル」からAndroid内部のファイルを直接閲覧できます。そこで、この「ファイル」の場所から見えるところに、ログファイルを置くように工夫します。

var contextRef = new WeakReference<Context>(this);
contextRef.TryGetTarget(out var c);
var dir = c.GetExternalFilesDir(null).AbsolutePath;
var filename = Path.Combine(dir, $"droid-{DateTime.Now.ToString("yyyyMMdd-HHmm")}.txt");
var tw = System.IO.File.OpenWrite(filename);
this.tr1 = new TextWriterTraceListener(tw);
DroidTrace.AutoFlush = true;
DroidTrace.Listeners.Add(tr1);
DroidTrace.WriteLine("ios Application Trace mode " + DateTime.Now.ToString());

ちょっとややこしいですが、GetExternalFilesDir関数を使うとアプリが公開しているフォルダを取得できます。このフォルダはアプリが他のアプリと共有するためのフォルダーになります。
このコードは、MainActivity::OnCreate に書いておけばよいです。

/storage/emulated/0/Android/data/<バンドル名>/files

「ファイル」のほうからは、スマホの機種名のとところから「/Andorid」のフォルダーから見つけることができます。アプリのバンドル名(net.moonmile.sample.testapp のようなもの)が含まれるので、かなり奥深いところになってしまいますが、一般ユーザーでもファイルを見ることができます。

public class DroidTrace
{
    static DroidTrace()
    {
        Listeners = new List<TraceListener>();
    }
    public static List<TraceListener> Listeners { get; }
    public static bool AutoFlush { get; set; } = true;
    public static void WriteLine(string message)
    {
        foreach (var it in Listeners)
        {
            it.WriteLine(message);
            if (AutoFlush == true) it.Flush();
        }
    }
}

DroidTrace クラスは共有プロジェクトの System.Diagnostics.Trace と重なっていしまうために、独自に作った簡易クラスです。作り方はiOS版と同じですね。

このように、GetExternalFilesDir で取得したパスに書き込むと Android の「ファイル」からログファイルを閲覧できるようになります。

Xamarin.Forms 側のパスを決める

となると、共有プロジェクトの方でも GetExternalFilesDir のパスに出力すれば良いことが分かるのですが、このパスは Android 内部で決まるものなので、共有プロジェクトでは使えません。
真面目にはるならば、DependencyService で DI するのが筋なんでしょうが、所詮 iOS と Android の違いしかないので、次のように直接パスを指定してしまいます。

var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
if ( Device.RuntimePlatform == Device.Android )
{
    // Android の場合は決め打ちにする
    dir = "/storage/emulated/0/Android/data/<バンドル名>/files";
}
var filename = Path.Combine(dir, $"log-{DateTime.Now.ToString("yyyyMMdd-HHmm")}.txt");
var tw = System.IO.File.OpenWrite(filename);
var tr1 = new TextWriterTraceListener(tw);
System.Diagnostics.Trace.AutoFlush = true;
System.Diagnostics.Trace.Listeners.Add(tr1);

というわけで、ちょっと雑ではありますが、ひとまず iOS と Android のログ出力環境がこれで整います。

カテゴリー: 開発 | Xamarin.Formsでログをファイル出力する(Android編) はコメントを受け付けていません