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編) はコメントを受け付けていません

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

Xamarin の場合、Visual Studio を通してデバッグ実行する場合は、System.Diagnostics.Debug か System.Diagnostics.Trace を使います。そのまま Visual Studio のデバッグ出力ウィンドウに表示されるので、手軽にプログラムの動作が確認できます。もともと、System.Diagnostics.Debug などは、.NET の Windows プログラミングで使われていたものなので、.NET であれば全般的に使えます。
つまり、ASP.NET でも、Blazor でも Azure Functions でも同じ様に使えるわけです。

同じ様に使えるということは、知識の使い廻しができる点で、

System.Diagnostics.Debug.WriteLine( message );

の使い方が、どの .NET 環境で利用できるということです。
余談ですが、Console のほうも同じで、標準出力にデータを書き出すという点は何処でも同じです。

System.Console.WriteLine( message );

なので、このような書き方をしても、どの環境であっても「標準出力」があれば出力がされます。Xamarin の場合には標準出力がないのでどこにも出力されません。ただし、別途標準出力を作ってやれば、目的の標準出力に出力されるでしょう。

デバッグ出力をファイルに書き出す

お手軽なデバッグ出力ではありますが、常に Visual Studio から起動しないといけないのはいささか面倒です。特に、スマホのアプリの場合は、スマホ単体でアプリを起動することが多く、テスト作業をするにしても Visual Studio から常に立ち上げるのは難しいでしょう。ブレークポイントを置いて何らかのチェックをしたい場合はもあるでしょうが、一連の動きをデバッグ出力としてファイルに保存しておくのがよいでしょう。

デバッグ先の出力ファイルを独自に作ってもよいのですが、ここでは System.Diagnostics.Trace のリスナーの機能を使ってみましょう。

ちなみに NuGet からライブラリを追加してよいのであれば、NLog を使う方法もあります。

NLog を使って Xamarin.Forms からログ出力する方法 – Qiita

実は、Trace には Listeners コレクションがあって出力先を追加できます。普段は Visual Studio のデバッグ出力にしか出ないのですが、これにファイルストリームを追加すると、トレース結果をファイルに出力できます。

Trace.Listeners Property (System.Diagnostics) | Microsoft Docs

var tw = System.IO.File.OpenWrite(filename);
var tr1 = new TextWriterTraceListener(tw);
System.Diagnostics.Trace.AutoFlush = true;
System.Diagnostics.Trace.Listeners.Add(tr1);

出力したいファイル名を OpenWrite 関数で開いて、TextWriterTraceListener オブジェクトを作ります。これを Listeners コレクションに Add するだけです。
AutoFlush を true にしておくのは、トレース出力(WriteLineなど)のたびにファイルに書き込むことを示しています。いちいち Flush するとスピードは遅くなるのですが、不意のアプリのクラッシュのときに、ログファイルが途中までしか書き込まれないときがあるので、AutoFlush させておいたほうが無難です。

ファイル名をどうするのかという問題がありますが、これも .NET で一般的に使われる Environment.SpecialFolder.MyDocuments あたりを使えば大丈夫です。Xamarin.Essentials でも良いのですが、所詮ファイルの作成先が作れればいいので、これでも大丈夫です。

var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var filename = Path.Combine(dir, $"log-{DateTime.Now.ToString("yyyyMMdd-HHmm")}.txt");

ファイル名は、起動したときの時分までで作成しています。こうすると、ファイル名がユニークになります。秒まであってもいいのですが、おそらくアプリのテスト起動は1分以内に行わないだろうから、これでいいでしょう。日付毎にまとめたい場合は、別途 OpenText などを使って工夫します。

もうひとつ、Info.plist で LSSupportsOpeningDocumentsInPlace を YES にしておきます。このようにすると、アプリのフォルダが「ファイル」に表示されるようになります。

<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>

こうすると、Xamarin.Forms の共通プロジェクトのほうでログ出力ができるようになります。
iPhone では次のように、ファイルを開いてログの状態が確認できます。

iOS 側のデバッグ出力をファイルに書き出す

これでテスト用のログファイル出力は十分、と思ったのですが、もうひとつ難関がありました。
Xamarin.iOS 側のプロジェクトで System.Diagnostics.Trace を使ってもデバッグ出力ができません。この理由は判らないのですが、Xamarin.iOS のほうのプロジェクトで、Trace.WriteLine としても、Xamarin.Forms 側の共通プロジェクトで出したログに合わせて出力されることはありません。多分、System.Diagnostics.Trace の実体がひとつしかないので、Xamarin.iOS 側から触れないようになっているのかもしれません。

仕方がないので、iOS側の Trace は自作します。最小限の機能で十分なので、こんな感じで WriteLine だけ作っておきます。

public class IosTrace
{
    static IosTrace()
    {
        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();
        }
    }
}

もとの System.Diagnostics.Trace と同じ様に Listeners コレクションに TextWriterTraceListener オブジェクトを追加すれば ok です。ファイル名は、Xamarin.Forms の共通プロジェクトで作ったものとは別にしておきます。

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

このようにしておくと、Xamarin.iOS 側で落ちたときのスタックトレースや微妙なコールバックの状態が判るようになります。簡易的なものなので、ファイル名や行番号などは付けていませんが、色々つけるような場合は NLog を使ったほうがいいかもしれません。

さて、iOS のほうがこれでいいのですが、Android の場合はどうなるでしょうか。というは話は、続きのブログ記事に書いておきます。

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

台東区ホームページの移行見積もり時間を見積もる

暫く忘れていた台東区ホームページ https://www.city.taito.lg.jp/ がリニューアルされました。この台東区のページ、去年の2月頃に「PHPはセキュリティに危ないから使ってはいけない」という要件が含まれていた案件です。ツイッター関係で、別に PHP がセキュリティ的に甘いのではなくて、作りが曖昧のだという議論があって、その後「バックエンドならば PHP をつかってもよい」という要件に変わったのですが、フロントエンドでは「動的生成をしてはいけない」というちょっと厳しめの案件でした。

入札なので、見積もり期間が短かった(2週間弱だったと思う)のと、6月スタート、12月運用開始という非常にハイスピードなため、どこかあらかじめ発注する場所が決まっているのではないか?と勘ぐってはみたいのですが、実際のところは結構堅めの会社が受注しました。台東区が出した予算枠が 5000万円だったのに対し、3000万円程度で受注した覚えがあります。

要件としては、

  • 「PHP はセキュリティに問題があるから避ける」と明言されていた
  • 実行時に DB は使わない
  • PC/スマホ共有は必須
  • ページ数は2000頁以上ある
  • 移行期間は6月から12月末までの6か月間
  • 博物館等の紹介ページも移行対象だった

というのが主なところでした。おそらくフロントエンドに WordPress を使わないようにさせるためではないか?という憶測もあり、じゃあ、バックエンドならばどうなのか、という議論もあるところです。

私が要件を見て一番のリスクだと思ったのは「博物館等の紹介ページも移行対象」のところです。当時、ざっとサイト眺めていたのですが、その手のページが無くて「多分、Flash とかなのでは?」と思ったものですが、ここがどう解決されているかは確認していません。
新しいサイトを見ると、もともと外部のサイトだったらしくリンクだけになっていますね。全てを見てないのでよくわかりません。

さて、旧台東区のホームページ トップページ 台東区ホームページ から、すべてのページが移行対象になります。台東区の担当者(あるいはコンサル?)が調べたところ 2000ページ以上あるらしいです。もともとの仕組みがどうなのかわかりませんが、すべてのページは *.html になっています。コードは、まあ、ある程度は整頓されているようでした。

要件の中に PC でもスマホでも表示できるように「レスポンシブルなページ」という用語があるので、何らかのコンサルが噛んでいる(あるいは担当者が詳しい?)ので、PHP のセキュリティが甘いという文言もそのあたりだったのでしょう。ちょうど、GMO だったかで .htaccess の不良があって wordpress がハックされるという騒ぎがあったので、それを受けての要件だと思います。

アクセス数は不明ですが、台東区民は20万人程度です。ゲームサイトのように台東区民がアクティブにサイトにアクセスする訳ではないのですが、日に10%程度の人がアクセスしたとして、2万位のアクセスになります。1時間に2000程のアクセス数しかないので、ある程度のキャッシュ用のプロキシを噛ませれば wordpress でも十分な気がしますが、要件としてはこんなところでしょう。

おそらく最終的に、

  • バックエンドで DB から静的 HTML を生成
  • フロントエンドで静的 HTML を返す
  • フロントエンドでレスポンシブルなページを作る

というのがシステム構成になっていると思います。静的 HTML を作るツールはいくつかあるのでしょうが(wordpress でも作れる)、問題となるのはシステム的なスピードよりも、移行対象となるページの多さです。最終的にどの位のページ数になったのかは不明ですが、要件段階で 2000ページ以上あることが明言されています。

作業項目(WBS)を見積もる

細々とした WBS を出す前に、大まかな作業項目を洗い出します。どうやら人海戦術になりそうな移行ページ数なので、そこが一番効いてきます。

  1. 移行前ページのクローリング&データ抽出
  2. 移行後ページの生成
  3. 移行後ページの動作確認

移行後のサーバー設定やらデータベース設定などはある程度見積やすいのですが、2000ページあると、ページ単位の作業量が問題になります。

商品販売のページとは違い、動的に変わる部分は少ない(過去の情報はそのまま変わらない)ので、単純に移行前のデータを取得して、移行後のページに整形し直すという作業になります。比率的にここが一番大きく、ひとつの作業(データ抽出→整形→動作確認)単位が 2000倍されることになります。

  • 1日で1ページならば、2000日 = 5人/半年 ペース

単純計算だとこうなります。それぞれのレイアウト込みの作業量なので、1日1ページが妥当かどうかわかりませんが、結構厳しめです。トータル予算には、前後のサーバー設定などの費用と時間がかかるので、それなりに掛かるでしょう。

開発期間は半年と決まっているので、全体の作業量を人月で割ることになるため、単純に人数を増やすしかありません。

効率化可能な場所を探す

人月商売ならば、上記の方法でページ単価を出して、ひとつず解決する。ページ単位の作業量から、全体の作業量を割り出せばいいのです。ですが、これだと作業量は変わらないので、薄利多売方式にしかなりません。

折角なので、IT 屋らしく、作業効率を高くできる場所を探します。

この場合、移行対象のページ数が多いので、ページ単位の移行作業を効率化すれば、全体の作業量がぐんと減ります。

  • ページ単位で自動化して、作業量を10分の1程度にする
  • 作業項目をひとまとめにして、作業量を10の1程度にする

分業化するあるいは自動化するのが効率化の常で、作業量は10分の1程度を目標値にします。
数パーセントの効率化では意味がないし、10分の1になる方法を考え出せれば、他社が追随できなくなります。いわゆる、社内ノウハウ、専門技術という訳です。

先の移行前/後のページ単位の作業は「手作業」を想定しています。ならば、この一連の作業を自動化させてしまうか、あるいは WBS 単位で 2000 ページの作業を圧縮させるかです。

で、考えらえるのが、

  • 移行前のページ抽出 → 移行後のページ出力 単位で自動ツールを作る
  • 移行前のクローリング → データベース保存をツール化する
  • データベースから、ページ出力を標準化する(レスポンシブル部分)
  • リンク切れなどのチェックを自動化する

移行後のページのコードを見ると「▼ヘッダーここから▼」等の作業用マークがあるので、実作業ではどこまで自動化していたのか不明ですが、「ページ出力を標準化」はきれいになされています。

以前は、大幅にカテゴリ単位でレイアウトが違ったところが標準化されています。
ただし、アイキャッチが所々入っているので、ページ出力に関してはかなり人手を使っているのではないかと想像できます。それでも小見出しやリストの表示は共通化されているので、作りやすくしてあるかなと。

所感

ともあれ、全体的には静的 HTML にしてあるので、体感的に表示が早くなっています。
実は、データベースを適切に配置させて、あまり入れ子にならないビュー専用の WordPress っぽいものを作るのと、静的 HTML 生成を動的に行えば似た感じのスピードは出せるので、静的 HTML にこだわる必要はないのですが、ここは「要件」なので仕方がない。

気象情報、緊急情報がトップページにあるので、災害時に20万人にリロードされるのは、トップページになります。いちばん重いのは jQuery 位で、初回に画像読みに少し時間が掛かるぐらいですね。災害時のメッセージ(現在では「12時間以内に配信した情報はありません。」になっているところ)は、Web API を呼び出して jQuery で埋め込んでいるようです。

カテゴリー: 開発 | 台東区ホームページの移行見積もり時間を見積もる はコメントを受け付けていません