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 で埋め込んでいるようです。

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

ラズパイで adb コマンドを使う

その昔、ラズパイで adb コマンドを使うのはえらい苦労した覚えがある(ARM なのでソースコードからビルドする必要があった)のだけど、さっくりと apt-get で可能になってた。

Install ADB and Fastboot on RasPi 3 – Raspberry Pi Forums

sudo apt-get install -y android-tools-adb android-tools-fastboot

自分を plugdev グループに追加

sudo adduser $LOGNAME plugdev

adb で接続

adb devices
adb shell 

orange pi の armbian でも大丈夫でした。これで Android の自動化が捗る…かもしれない。

カテゴリー: 開発 | ラズパイで adb コマンドを使う はコメントを受け付けていません

Vue.jsとChart.jsでCovid19の観測サイトを作る

Vue.js に慣れるため、という名目で Covid 19 の観測サイトを作成してみます。

実のところ、Covid 19 の観測サイトは既にたくさんあります。

ただ、データ集計をして表示するだけならば色々あるのですが、

  • 観測データを動的に比較する
  • 観測データを使って、何かシミュレーションする

ことができません。以前 プログラマにもわかる SEIR モデルシミュレーション – Qiita ということで、SEIR モデルで Excel を使って予測値を出していたのですが、これは毎日手作業でやっていました。データ整形がちょっと面倒だったのと、予測 Rt 値を恣意的に(数日の平均など)を使っていくつかシミュレーションしていたのです。

で、先日 NHK サイトで二次利用可能なオープンデータとして使えることが分かったので、これを使って自動化してみます。

CSV 形式を加工する

Vue.js(JavaScript) で NHK の CSV 形式のデータをパースしてもよいのですが、既に JSON 形式になっていたほうが楽なので、中間の Web API を作成します。

Azure Function のタイマートリガー

タイマートリガーで1時間単位でCSV形式のファイルをダウンロードします。
Covid 19 のデータは1日単位でしか更新されないので、もっとスパンが長くてもよいのですが、何時頃公開されるか判らないので1時間単位。

プログラムを最初に書いたときは、いちいちCSV形式のデータをダウンロードしていたのですが、結構遅いので、ダウンロードして JSON 形式にパースしたら BLOB に保存しています。

[FunctionName("NHKCovidTimer")]
public static async Task RunTimer([TimerTrigger("0 5 * * * *")] TimerInfo myTimer,
    [Blob("covid/japan.json", FileAccess.Write)] Stream jsonfile,
    ILogger log)

{
    log.LogInformation("called NHKCovidTimer");
    var url = "https://www3.nhk.or.jp/n-data/opendata/coronavirus/nhk_news_covid19_prefectures_daily_data.csv";
    var cl = new HttpClient();
    // 1行ずつ読み込み JSON 形式に変換
    var res = await cl.GetAsync(url);
    var data = new List<Covid>();
    using (var st = new StreamReader(await res.Content.ReadAsStreamAsync()))
    {
        // タイトルは読み飛ばし
        st.ReadLine();
        while (true)
        {
            string line = st.ReadLine();
            if (string.IsNullOrEmpty(line)) break;
            var items = line.Split(",");
            if (items.Length >= 7)
            {
                var it = new Covid()
                {
                    Date = DateTime.Parse(items[0]),
                    LocationId = int.Parse(items[1]),
                    Location = items[2],
                    Cases = int.Parse(items[3]),
                    CasesTotal = int.Parse(items[3]),
                    Deaths = int.Parse(items[3]),
                    DeathsTotal = int.Parse(items[3]),
                };
                data.Add(it);
            }
        }
        // ソートしておく
        data = data.OrderBy(t => t.LocationId).ThenBy(t => t.Date).ToList();
        // 週平均を計算
        calcCasesAve(data);
        // 週単位Rt値を計算
        calcCasesRt(data);
        // 週単位Rt平均値を計算
        calcCasesRtAve(data);
    }
    var json = JsonConvert.SerializeObject(new { result = data });
    var writer = new StreamWriter(jsonfile);
    writer.Write(json);
    writer.Close();
    // return new OkObjectResult("save json " + DateTime.Now.ToString());
}

CSV 形式をパースするだけでなく、あらかじめ

  • 週平均
  • 週単位のRt値
  • 週単位のRt平均値

などを計算しておきます。

保存する JSON の形式は Covid クラスに定義しています。大文字をわざわざ小文字に変えているのは、Vue.js の読み取りに合わせたためです。

public class Covid
{
    [JsonProperty("date")]
    public DateTime Date { get; set; }
    [JsonProperty("locationId")]
    public int LocationId { get; set; }
    [JsonProperty("location")]
    public string Location { get; set; }
    [JsonProperty("cases")]
    public int Cases { get; set; }
    [JsonProperty("casesTotal")]
    public int CasesTotal { get; set; }
    [JsonProperty("deaths")]
    public int Deaths { get; set; }
    [JsonProperty("deathsTotal")]
    public int DeathsTotal { get; set; }

    [JsonProperty("casesAverage")]
    public float CasesAverage { get; set; }   // 週移動平均
    [JsonProperty("casesRt")]
    public float CasesRt { get; set; }        // 週単位Rt値 = 続く1週間の感染者数平均 / 当日感染者数  
    [JsonProperty("casesRtAverage")]
    public float CasesRtAverage { get; set; }     // Rt値の週移動平均
}

HTTP トリガーを定義する

Web API は非常に簡単で、HttpTrigger で JSON ファイルの中味を返すだけです。データ量が 3M 程度になって大き目になってしまったので、後で期間や都道府県で絞れるように修正します。

[FunctionName("NHKCovid")]
public static async Task<IActionResult> RunRead(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = null)] HttpRequest req,
    [Blob("covid/japan.json", FileAccess.Read)] Stream jsonfile,
    ILogger log)
{
    log.LogInformation("called NHKCovid");
    var sr = new StreamReader(jsonfile);
    var json = await sr.ReadToEndAsync();
    return new OkObjectResult(json);
}

Vue.js + Chart.js 側のコード

例えば、陽性者数のグラフは、Web APIの中から cases の値を羅列するだけの簡単なものです。
都道府県を複数選択できる(this.valueにある)ので、これの分だけ datasets を作ります。Chart.js は datasets の中に線グラフの色などが入っているので、その部分は二重に定義することになります。ここでは、最低限の定義しかしていません。

Chart.js は vue-chartjs を使っています。

/**
 * 感染者数のグラフを作成
 */
makeCases(res,start_date,end_date,locations) {

    var sdate = Date.parse(start_date)
    var edate = Date.parse(end_date)
    var datasets = []
    var labels = []

    var i = 0;
    locations.forEach(location => {
    var data = [];
    var data2 = [];
    labels = [];
    res.data.result.forEach(el => {
        if ( el.location == location ) {
        var dt = Date.parse( el.date )
        if ( sdate <= dt && dt <= edate ) {
            dt = new Date(dt)
            dt = dt.getFullYear() + "/" + (dt.getMonth()+1) + "/" + dt.getDate() 
            labels.push( dt )
            data.push( el.cases )
            data2.push( el.casesAverage )
        }
        }
    });
    var dataset = 
        {
        label: location,
        fill: false,
        borderColor: i >= this.colors.length? "rgba(200,200,200,0.5)": this.colors[i].n,
        data: data
        }
    var dataset2 = 
        {
        label: location + "(週平均)",
        fill: false,
        borderColor: i >= this.colors.length? "rgba(100,100,100,0.5)": this.colors[i].ave,
        data: data2
        }
    datasets.push( dataset )
    datasets.push( dataset2 )
    i++;
    })
    return { labels, datasets };
},

都道府県を再選択したときにグラフを再描画させます。しかし、Chart.js がデータの更新による再表示に対応していないので、ごっそりデータの中味を書き替えて更新を通知するという方式をとっています。クローンは JSON.parse(JSON.stringify(…)) で作ると安全にできます。

async getData() {
    var url = process.env.VUE_APP_NHK_COVID_API_URL
    console.log( url )
    var res = await axios.get(url);

    var { labels, datasets } = this.makeCases( res, "2020-10-01", "2021-12-31", this.value )
    var { labels2, datasets2 } = this.makeCasesRt( res, "2020-10-01", "2021-12-31", this.value )
    this.datax.labels = labels ;
    this.datax.datasets = datasets ;
    this.datart.labels = labels2 ;
    this.datart.datasets = datasets2 ;

    var { labels3, datasets3 } = this.makeCasesFuture( res, "2020-12-01", "2021-12-31", this.value )
    this.datafu.labels = labels3 ;
    this.datafu.datasets = datasets3 ;

    var data = this.makeCases2( res, "2020-10-01", "2021-12-31", this.value )
    this.datax2.labels = data.labels ;
    this.datax2.datasets = data.datasets ;
    // 再描画の代わり
    this.datax = JSON.parse(JSON.stringify(this.datax));
    this.datax2 = JSON.parse(JSON.stringify(this.datax2));
    this.datart = JSON.parse(JSON.stringify(this.datart));
    this.datafu = JSON.parse(JSON.stringify(this.datafu));
},

本来ならば、この this.datax まわりを Vuex の Store に詰め込めばいいのですが、これも後で変えましょう。4つのグラフが並んでいると、さすがに面倒臭いので。

予測値を計算する

予測の計算が試行錯誤がやりやすいように、JavaScript 側で計算しています。

  • 感染期間を7日間として Rt を計算する
  • Rt 値から週平均 Rt 値を計算する
  • 週平均 Rt 値から、未来の日の陽性者数を計算する

確定した週平均 Rt 値(精度上、1週間前の値が確定値になる)を使って、前進的に予測します。

/**
 * 最新の実効再生産数から今後1か月の感染者数を予測
 */
makeCasesFuture(res,start_date,end_date,locations) {

    var sdate = Date.parse(start_date)
    var edate = Date.parse(end_date)
    var datasets = []
    var labels = []
    var i = 0;

    console.log( edate )

    locations.forEach(location => {
    // 最終日を取得
    var lastdate = null 
    var last = null
    res.data.result.forEach(el => {
        if ( el.location == location ) {
        var dt = Date.parse( el.date )
        if ( el.casesRt > 0.0 ) {
            if ( lastdate < dt ) {
            lastdate = dt 
            last = el
            }
            if ( sdate <= dt ) {
            dt = new Date(dt)
            dt = dt.getFullYear() + "/" + (dt.getMonth()+1) + "/" + dt.getDate() 
            if ( i == 0 ) labels.push( dt )
            }
        }
        }
    })
    console.log( last );

    var data = [];
    var data2 = [];
    // 実測値を集計
    res.data.result.forEach(el => {
        if ( el.location == location ) {
        var dt = Date.parse( el.date )
        if ( el.casesRt > 0.0 ) {
            if ( sdate <= dt ) {
            data.push( el.cases )
            data2.push( el.casesRtAverage )
            }
        }
        }
    })
    // 予測値を計算
    var rt = last.casesRtAverage ;
    for ( var j=1; j<=40; j++ ) {
        // 過去7日間の cases と Rt から予測 cases を計算する
        var len = data.length ;
        var cases = (
        data[ len-7 ] * data2[ len-7 ] + 
        data[ len-6 ] * data2[ len-6 ] +
        data[ len-5 ] * data2[ len-5 ] + 
        data[ len-4 ] * data2[ len-4 ] + 
        data[ len-3 ] * data2[ len-3 ] + 
        data[ len-2 ] * data2[ len-2 ] + 
        data[ len-1 ] * data2[ len-1 ] ) / 7.0 ;
        data.push( Math.floor(cases))
        data2.push( rt );

        var dt = new Date(Date.parse( last.date ))
        dt.setDate(dt.getDate() + j);
        dt = dt.getFullYear() + "/" + (dt.getMonth()+1) + "/" + dt.getDate() 
        if ( i == 0 ) labels.push( dt )
    }

    var dataset = 
        {
        label: location,
        fill: false,
        borderColor: i >= this.colors.length? "rgba(200,200,200,0.5)": this.colors[i].n,
        data: data,
        yAxisID: "y-axis-1", 
        }
    var dataset2 = 
        {
        label: location + "週平均Rt",
        fill: false,
        borderColor: i >= this.colors.length? "rgba(100,100,100,0.5)": this.colors[i].ave,
        data: data2,
        yAxisID: "y-axis-2", 
        }
    datasets.push( dataset )
    datasets.push( dataset2 )
    i++;
    })
    return { labels3: labels, datasets3: datasets };
},

実行

https://moonmile.net/nhkcovid/

陽性者数

陽性者数予測

コード

moonmile/NHKCovid: Covid 19 の観測サイト

カテゴリー: 開発 | Vue.jsとChart.jsでCovid19の観測サイトを作る はコメントを受け付けていません

具体的に Vuex と MVVM を比較する

MVVM を習得している人に Flux な Vuex をどう使ったら学習効率が良さそうなのか?

まずは Blazor で

Redmine の projects テーブルの一覧を表示する画面を作成します。

MVVM パターンに分離せず、ひとつの Projects.razor ファイルに作成しています。

@page "/project"
@inject HttpClient Http

<h1>プロジェクト一覧</h1>
<table class="table">
    <thead>
        <tr>
            <th>ID</th>
            <th>名前</th>
            <th>説明</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in projects)
        {
            <tr>
                <td>@item.Id</td>
                <td>@item.Name</td>
                <td>@item.Description</td>
            </tr>
        }
    </tbody>
</table>

@code {
    private Project[] projects = new Project[] { };

    protected override async Task OnInitializedAsync()
    {
        var url = "http://localhost:8000/api/project";
        this.projects = await Http.GetFromJsonAsync<Project[]>(url);
    }

    public class Project {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
    }
}
  1. ルーティングは @page で指定します。これは Vue Router よりちょっと便利。
  2. データは、this.projects で参照できるように。Vue で言えば data に保持している感じ
  3. Web API の呼び出しは HttpClient をインジェクションして利用します。HttpClient は、連続して何度も呼び出すとタイムアウトが重なっていまってメモリを喰うという欠点があるのです。このため、Web 絡みではひとつの HttpClient を使いまわします。

.NET5 の HttpClient には、GetFromJsonAsync という拡張メソッドが入って、Newtonsoft の Json ライブラリがいらなくなりました。取り込んだのか、別途実装したのかは不明ですが、単純に JSON をクラスに取り込むだけならばこれでいけます。

Web API で取得できる JSON の中味はさておき、取り出したいキーだけをプロパティとして設定しておきます。実は、日付とかブール値とかは自動で変換してくれないので、別途コンバータか自前変換が必要なのが、いまいちなところです。まあ、単純なデシリアライズならばこれで十分かも。

Newtonsoft.Json から System.Text.Json に移行する – .NET | Microsoft Docs

データストア的には、プロジェクトの一覧を保持している projects だけなので、Vuex を使うと冗長すぎて比較としてはフェアではないのですが、これを Vue/Vuex を使って書き直します。
(実際は、Vue.js で書いた後に Blazor で書いていますが)

次に Vuex で

実のところ、Vue 側は、

  • Vue
  • Vuex
  • Vue Router
  • Vuetify

を使っています。

なので、Blazor 版よりも構造化されているのですが、ひとまず、標準的(と思われる)Vuex の使い方です。

Web API は axios を使っています。直接 store.js のほうに突っ込んでもいいのですが、Web API なライブライ的に使い廻せるところは外部に題しておきたいところです。

JavaScript の場合、ライブラリをどう作るのか不明(昔の prototype しか知らないのであった)なので、こんな感じに作って置きます。Web API の呼び出しの場合、

  • Web API 自身のエラーをどうするのか?
  • 認証系のエラーをどうするのか?
  • パラメータ不正の場合はどうするのか?

というややこしい話がありますが、大抵の場合は正常に返ってくるものと想定して作ります。そして、不正になった場合は、そのまま例外が飛ぶので、UI 側でエラーになります。

本来ならば、フェールセーフ的に axios の例外が発生したときは、しれっと projects の空配列を返したいところです。

webapi/redmine.js

import axios from 'axios'

export default {
    project: {
        async all() {
            var res = await axios.get('http://localhost:8000/api/project');
            var items = res.data ;
            return items ;
        },
    },
}

データストアは、こんな感じで書きます。
state, mutations, getters のところは普段通りの定番。getters は使わなくてもよいのですが、統一的に map できるように作っておきます。
そして、actions の中から定義済みの Web API を呼び出します。

store.js

import Vue from 'vue'
import Vuex from 'vuex'
import redmine from '@/webapi/Redmine'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        projects: [],
    },
    mutations: {
        projects(state, payload) {
            state.projects = payload.projects
        },
    },
    getters: {
        projects(state) {
            return state.projects
        },
    },
    actions: {
        async projects({commit}) {
            var items = await redmine.project.all();
            commit('projects', { projects: items })
        },
    },
})

それぞれの名前を "projects" に統一させてしまっているのは、MVVM パターンに似せるためです。state に対する get/set が異なる名前になるとプロパティの設定/取得としては違和感があります。逆に言えば、get/set を統一的に使いたくない場合(非対称な場合)は、この projects の部分は名前を変えたほうがいいでしょう。

おそらく、検索結果をフィルターする場合には、getters の名前を変えることになるかな、と。実際、Vuex のサンプルではそうなっています。

この store.js を活用するのが Project.vue です。

Project.vue

<template>
  <v-container>
      <h2>プロジェクト一覧</h2>

      <v-data-table
        :headers="headers"
        :items="items"
        :items-per-page="5"
        class="elevation-1"
      ></v-data-table>

  </v-container>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'

export default {
  name: 'Project',
  data: function() {
    return {
      headers: [
        { text: 'id', value: 'id' },
        { text: 'プロジェクト名', value: 'name' },
        { text: '詳細', value: 'description' },
      ],
    }
  },
  computed: {
    ...mapGetters({
      items: 'projects',
    })
  },
  mounted() {
      this.getProjects()
  },
  methods: {
    ...mapActions({
      getProjects: 'projects', 
    })
  }
}
</script>

テーブル表示に Vuetify の v-data-table を使っています。
getters と actions の dispatch は直接呼び出したくないので、mapGetters と mapActions でマッピングさせます。

store.js 内では projects として統一した名前にしていましたが、マッピングするときに利用しやすように名前を変えます。Web API を呼び出すだけであれば、Project.vue のコード内には this.$store は出てきません。

この画面の状態を data に持たせるのか、store.js のほうに書くのかは悩むところではありますが、MVVM パターンのように記述するのであれば、UI の状態も store.js の方に記述するのがベターでしょう。MVVM の View(Viewのバックエンドのコード)に状態を持たせてしまうと ViewModel との整合性があわなくなる、かつ再帰テストがやりづらくなるので、この手の状態値も ViewModel の方に押し込んでしまいます。

その形にならうならば、Vuex のほうも .vue のほう記述するよりも store.js に押し込んでしまったほうがよいでしょうね。
ただし、いくつか書いてみる判るのですが、View である .vue から store.js は比較的遠い位置にあります。上記の headers 配列やダイアログの表示フラグのような View のみに関するところは、.vue の data で十分なような気がします。いや、むしろ View のほうにいれておかないと、部品的なコンポーネントを作るたびに store.js を改修せねばならず、かなり面倒です。「コンポーネント」として分離されていません。

Vue の画面単位で Vuex のモジュール分けをしたほうがよいのか?

Microsoft 的な MVVM パターンの使い方で言えば、Vue のひとつのコンポーネントに対して Vuex のデータストアがあることになります。Vuex で言えば、モジュール分けになります。

Vuex でモジュール分けをすると名前空間で分けられるのはいいけど、相互に値をやり取りするたびに rootState が必要になって、これまたちょっと変な構造になります。
この部分は、もうちょっと考察が必要。

カテゴリー: 開発 | 具体的に Vuex と MVVM を比較する はコメントを受け付けていません

Flux を Blazor に活用してみる

.NETで作れるブラウザ上のシングルアプリケーション Blazor は、仮想 DOM を使っていて、内部的には(多分)Vue.js とよく似ています。

Blazor の内部動作は Razor なので(ASP.NET の Razor)、SPA内部でのデータ構造は自由になります。動作的には MVVM パターンな View との双方向バインドになっていますが、たまに StateHasChanged を呼び出して画面を更新しないといけないのが、いまいちイケていないところです。

通常の Blazor の場合

Web API を呼び出して、その結果を表示させる場合、Blazor ではこんな感じになります。

@page "/normal"
@inject HttpClient Http
<h3>Normal Sample</h3>

<button class="btn btn-primary" @onclick="save">Click me</button>
<div>
    @Result
</div>

@code {
    private string Result { get; set; }

    private async void save()
    {
        // Web API 呼び出し
        var url = "http://localhost:8000/api/project";
        var res = await Http.GetAsync(url);
        // 結果を mutations へ commit する
        this.Result = await res.Content.ReadAsStringAsync();
        this.StateHasChanged();
    }
}

Vue.js ならば data で result で保持するところを、Blazor ではプロパティ(実はフィールドでも良い)で持ちます。@code な部分は、Vue.js で言えば script なところです。Blazor では暗黙的に Blazor コンポーネントのクラスに割り当てられるので、このコードは、Normal クラスの内部メソッドやプロパティになります。

「Click me」なボタンを押したときに、save メソッドに割り当てる部分も、Vue.js と同じです。

Blazor の元ネタである Razor の場合(それ以前でいれば Web フォーム)も、こんな形でメソッドに割り当てることができます。Web フォームの場合は、JavaScript を駆使して該当するメソッド(実際はサーバーで動作する)に割り当てていたのですが、Blazor の場合はブラウザ上で動作する wasm 内で動くので完全にクライアントサイドで動作する save メソッドになります。

StateHasChanged メソッドは、仮想 DOM が更新されたのでレンダラーを動かす印なのですが、これが必要な場合と必要がない場合があります。大抵の場合は大丈夫なのですが、HttpClient で非同期に動いたときは必要っぽいです。

この部分は MVVM パターンで言えば OnPropertyChanged なのですが、Blazor の StateHasChanged はコンポーネント単位(この場合は、Normal.razor)への通知になるので、微妙なところです。たくさんのプロパティがあるときに、StateHasChanged により動作が遅くなるのかどうかは不明です。ちなみに、SVG でライフゲームを作ったとき 100 x 100 のプロパティを変更させていますが、それほど重くはありません。1万個のプロパティがあっても大丈夫なので、大抵の場合はスピード的に問題はないのでしょう。

動作させるとこんな感じになります。


Vuex っぽく書き替える

これを Vuex っぽく書き直してみます。Blazor 上のいくつかの Flux 実装をみると、dispatch や commit などの名前を使っていますが、名前自体に意味はない(アーキテクチャなので)ので C# のプロパティとメソッドを使って構築します。実は Vuex の書き方って Command パターンの悪癖の典型ような気がするので、これは型付の言語にはなじまないですよね。Blazor の場合は、コンパイルが間に挟まるので、プロパティ名やメソッド名にきちんと意味を持たせた方が、開発的に安全です。

namespace FluxBlazor
{
    public class Store
    {
        // state:
        private string _result = "";

        public string Result
        {
            // getters 
            get => _result;
            // mutations 
            set
            {
                _result = value;
                OnStateHasChanged?.Invoke();
            }
        }
        public event Action OnStateHasChanged;

        HttpClient Http = new HttpClient();
        // actions: 
        public Task Save()
        {
            var task = new Task(async () =>
            {
                // Web API 呼び出し
                var url = "http://localhost:8000/api/project";
                var res = await Http.GetAsync(url);
                // 結果を mutations へ commit する
                this.Result = await res.Content.ReadAsStringAsync();
            });
            task.Start();
            return task;
        }
    }
}

Store.cs という新しいクラスを作って、そこに Flux の各種の値とメソッドを書いていきます。

  • state は、外部から見えないようにするため、private で定義
  • getters は、プロパティの get を使う
  • mutations は、別のメソッドにしようと思ったけど、意味としてはプロパティと set と同じなので、ここで定義する
  • actions は非同期処理になるので、常に Task を返す。

Vue.js の場合は、mutate による state の変化が、そのまま仮想DOMに伝わります。
しかし、Blazor の場合は、いちいち仮想DOMを更新しないといけないので、OnStateHasChanged イベントを発生させて、その中で StateHasChanged を呼び出して貰います。StateHasChanged の処理自体は、コンポーネントに紐づいているので、現在描画しているコンポーネントの StateHasChanged メソッドを呼び出す必要があるのです。

これ App クラス内に作って呼び出せば大丈夫かと思ったけど、駄目でした。あくまで、コンポーネント単位で StateHasChanged を呼ばないと駄目みたい。

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

@code {
    public static Store store { get; set; } = new Store();
}

Store の実体(オブジェクト)を何処におくか悩んだのですが、App.razor に書きます。Vuex の場合は、App.vue 内になるので、ちょうどそれと同じです。
Store オブジェクトは、どのコンポーネントからも参照ができるように static にしておきます。これで、App.store として参照できます。Vuex の場合は this.$store になるので、これも同じ感じです。

Flux.razor を新しく作って、Store クラスにある Web API を呼び出すようにします。

@page "/flux"

<h1>flux blazor sample</h1>

<button class="btn btn-primary" @onclick="save">Click me</button>
<div>
    @Result
</div>

@code {

    private string Result => App.store.Result;

    protected override void OnInitialized()
    {
        base.OnInitialized();
        // 変更イベントを受け取る
        App.store.OnStateHasChanged += () => { this.StateHasChanged(); };
    }

    private void save()
    {
        Console.WriteLine("click save");
        // dispatch 
        App.store.Save();
    }
}

Web API の結果を表示する Result プロパティは、App.store.Result 自身を返します。これは、ちょうど Vue.js で算出プロパティを使って Vuex の getters を呼び出すところと同じです。

画面の初期化時には、OnStateHasChanged イベント内で StateHasChanged メソッドを呼び出すようにします。これで、Store 内の mutations の更新で自動で画面が更新されます。

ボタンを押したときの save メソッドでは、Store クラスの Save メソッドを呼び出します。これは Vuex の dispatch にあたる処理です。

これを実行してみると、Normal.razor と同じ動作になります。


まるで MVVM パターンのようだ

実際に書いてみるとわかりますが、実質 MVVM パターンと変わりません。Flux の場合は、state に対する読み取り(getters)と書き込み(mutations)を分けていますが、C# の場合はプロパティの get/set で十分です。引数がある場合は、別途メソッドを作ることになるでしょうが、値の出し入れだけなればプロパティで十分です。

MVVM と Flux/Vuex の大きな違いは、actions のところでしょう。
最初のノーマルな Blazor のコードの場合は、Web API 呼び出しの後に await を使ってレスポンスを取得、そして取得結果を表示しています。
Flux 風の場合は、非同期な save メソッドを用意しておいて、そこで Task オブジェクトを作って呼び出しています。Flux 風の場合は、Save メソッドを呼び出した直後では Reuslt の値は変更されません。あくまで、Save メソッドは Web API を呼び出すだけで、結果待ちはしていないのです。これは複数の Web API を actions 内に記述したときも同じです。このため Web API のレスポンスを受けて Result プロパティが変更されるタイミングを知るためには、通常の MVVM パターンとは違った方法が必要です。Save メソッドの戻り値ではないので、Vuex の then にあたる ContinueWith でメソッドチェーンをするか、await で同期待ちをします。

Blazor の場合、まだ始まったばかりなので、データの保持が MVVM がよいのか Flux がよいのか判然としませんが、Blazor のコンポーネント単位で ViewModel を持たせるよりも、App クラスに一括して持って持っておいたほうが画面遷移があるときは便利かもしれません。デスクトップアプリの WPF や UWP の場合でもたびたび画面遷移で ViewModel をどう引き継ぐのかというのが問題になるので、似たような議論は Blazor 界隈でおこるでしょう。

ちなみに、デスクトップアプリの ViewModel の取り回しは、子の ViewModel を作るときに、親の ViewModel を引き渡してしまうのが簡単です。

ViewModel _vm ;

var v = new ChildView( _vm );
v.ShowDialog();

こんな感じで、子ウィンドウを開くときに、自分の ViewModel を渡してしまいます。このようにすると、子のダイアログ内で親の変数を扱うときにも便利なので(親画面で表示しているリストの変更やラベルの変更など)、定型的に使うとよいです。

カテゴリー: 開発 | Flux を Blazor に活用してみる はコメントを受け付けていません

MVVM パターンと Vuex を比較して理解する

とあるところで、Laravel + Vue.js で社内ツールを作ることになりました…と仮定しましょう。自分としては WPF とかで作ったほうがノウハウがあるので手軽に作れるのですが、先方の要望といういうか、既にあるツールが Vue.js の electron で作ってあるのと、サーバーサイドが Larevel で Web API で作ってあるのでそれに準じましょうという具合です。

ちなみに、私としては Vue.js は半年前に始めたばかりで、それも Laravel の View 上に Vue.js のスクリプトを読み込ませる方式で作っていました。なので、プログラミングの作法としては、Laravel 寄り(PHP寄り)になるので、いまいち Vue.js は判らないのです。JavaScript が苦手というのもありますが。

漠然と Vue.js オンリーで作っている場合は、適当な data を作ってため込むという VB や Windows フォームな作りで十分な訳ですが、ある程度の規模が大きくなると(どの位が「ある程度」というのか疑問はありますが、10画面ぐらいあれば「ある程度」と言えるでしょう)、なんらかの形でデータ層を用意しておいたほうがベターです。

そのような場合、Windows のデスクトップならば WPF で作る、Android や iOS アプリならば Xamarin.Forms を使うという形で MVVM パターンを使うのが常です。View 側に XAML を使って、ロジックに ViewModel を使う。データベースのアクセスなどに Model クラスや Entity Framework を使えばよいので、(自分の中では)かなりノウハウが蓄積しています。Prism を使うという手もあるのですが、私の場合それほど複雑な形にしたくないので毎度 INotifyPropertyChanged を継承したクラスを自作(実際には使いまわし)しています。ICommand を使わないからこれで十分なのです。

MVVM パターンとは何か?

WPF や Xmarin.Forms や、今や瀕死状態の UWP(何処にいちゃったんでしょうね?.NET5 の UI ラインナップにも無かった気が…)でおなじみの MVVM パターンです。実は、Laravel や CakePHP や、以前の mac のように MVC パターンも一緒に覚えておくといいです。Objective-C で mac アプリを使うときは MVC パターンだし。でも今は、Swift か。

画面の View は、大抵の場合は画面専用のマークアップ言語で書きます。WPF であれば XAML、XAML 以外に MVVM パターンの View があるのか?と言えば、探せばあるんじゃないだろうか?な感じですが、MVVM パターンとしては、View 自体は XAML に限るという訳ではありません。ただ、現状の実装としては XAML のようなスタイルにしないと(INotifyPropertyChangedの連携があるので)、うまく実装できないというところです。

Model は、いわゆる値クラスでもあり、データベースから取ってきた Entitiy Framework でもあり、データを保持するところです。

この View と Model の間を取り持つのが ViewModel クラスな訳ですが、一般的な Microsoft 社のサンプルコードだと、ひとつの View に対してひとつの ViewModel が律儀に割り付けられていますが、そうしないといけない訳ではありません。見通し的には、1対1対応のほうが判りやすいという面もあるのですが、共通なデータを持たせる場合(特に画面間でのデータが共通である場合)View – ViewModel の対応が冗長になってしまうので、ひとつの ViewModel に複数の View を持たせるという、Document-View 方式でも良いわけです。

MVVM パターンも MVC パターンも使われ始めた頃(20年前位?)はいろいろと学習しないと訳が分からない感じでしたが(オブジェクト指向とかオブジェクト脳とかUMLとか諸々の波もあったので)、いまとなっては一般的なアプリケーションの View と Model をうまく分離&組み合わえるパターンのひとつです。私的には「MV* パターン」とまとめて呼ぶこともあります。

Vuex とは何か?

Vue.js でコンポーネントを作るときに v-model というのがあるので、MVVM パターンでええやん!と思うわけですが、Vuex というものを使います。正確には Flux アーキテクチャを使って Vue.js に作られた Vuex を使います。

データの取り回しとか、Vue.js であれこれやるのに精いっぱいなのに(初学者的には!)、更に Vuex の方式を覚えないといけないという混乱状態です。実は Vue Router も思えないといけないのと、Vuetify も使わないといけない。

自分としてはできるだけ既知のもの、あるいは「これはいけそう」と思えるものを使いたいので、既に MVC パターンや MVVM パターンがあるところに Vuex という発想は必要なのか?という疑問がでてきます。

Vuex ではおなじみのこのぐるぐるの図ですが、遷移が一方向にしか回らないので理解が簡単!ということなのですが、本当なのでしょうか? MVVM パターンのほうが相互にやり取りする(two way方式)ので、簡単ではないでしょうか?

それにいちいち、dispatch や commit を呼び出すというのはどういうことなのか?メソッド呼び出しでいいんじゃないだろうか?という不信が湧き出るわけです(今でも、ちょっと湧き出るけど)。

でもって、google で「Vuex 使わない」で検索すると、ぽろぽろと出てきますね。疑似的に MVVM パターに則ったライブラリを自作してもよいのですが、Vuex は公式から出ているということで、それなりの理由があるのでしょう。では「それなりの理由」とは何ぞや?というところから、理解したいし、それをした上で使うのか使わないのかを判断したいわけです。

シーケンス図で理解する Vuex

実は、さっきのぐるぐるの図には、ひとつ大きな見落としがあります。
主に Actions からの呼び出しは Web API に対して非同期に行われています。

デスクトップアプリや、サーバーサイドでのデータベースアクセスは、基本は同期的に行われています。現在の C# だとファイルアクセスするときに async/await を使い「非同期」として呼び出すことができますが、本質的に await で処理待ちをしていれば同期的に操作をしているし、通常の場合は印刷や重たい画像処理以外は同期的に書いていてもアプリケーションのレスポンスはCPUのスピードに関係するところが多いので、それほど問題にはなりません。

しかし、Vue.js のように、基本何かのデータを保存したり読み込んだりするのに常に Web APIを呼び出す必要があるときには、何らかの処理のほとんどが非同期的におこなわれます。実装としては、axios クラスを使って処理待ちの then を使うわけです。

なので、コンポーネント(View)でユーザーがボタンクリックなどのアクションを起した後に、Web APIを呼び出して、応答されたデータがリストに表示されるまでにはそれなりの時間が掛かり、この部分はCPU的には連続ではないのです。

Vuex の解説で使われるステートチャート(遷移図)だけでは、時系列が判りづらいので、具体的にシーケンス図で書いたのがこれです。

画面を表示したときの mounted のときに、Web API の api/Rooms を呼び出して、画面に会議室の一覧を表示する画面を想定したものです。

  1. コンポーネントから dispatch する
  2. actions から Web API を axios で呼び出す。
  3. レスポンスは、非同期になり Promise を使う。
  4. ここでいったん画面に制御が戻る
  5. レスポンスが返ってきたときに、結果を commit する
  6. 書き込み専用の mutations で state に書き込む
  7. 読み込み専用の getters から stete の変更がコンポーネントに通知される
  8. コンポーネント側の rooms が更新されて、画面にリストが表示される。

という具合です。なるほど、dispatch が必ず Promise を返す=非同期である、というのがここに意味があります。内部で、Web API を呼び出さない同期的な処理であっても、Promise を返すので、画面のコンポーネント側では常に「非同期呼び出し」として処理が統一できます。

つまり、内部動作が非同期処理か同期処理かを区別せずに、(乱暴ですが)すべて非同期処理とみなす、という方法を Vuex は取っています。

同じものを MVVM パターンで書いてみましょう。画面を表示させたときに、Web API を呼び出してリストの初期状態を取得する、というパターンはよくあります。

View がロードしたときに ViewModel の呼び出し自体は同期的に行われます。しかし、ViewModel 内では、Web API を呼び出す(HttpClientなど)ので、これの戻り値の取得は非同期になります。
実際は Task オブジェクトが返されるのですが、コード上では実行待ちのように記述したいので await を使います。

Web API からレスポンスが返ってきたときは、Model クラスに同期的に書き込みが行われます。Model クラスの変更に伴い、ViewModel が View に対して OnProertyChange イベントを発生させます。このイベントを受けて View の該当箇所が更新されます。

シーケンス図を書く Vuex と MVVM パターンの類似点がよくわかります。
少なくとも「非同期で Web API を呼び出す必要がある」場合には、Vuex も MVVM もまったく同じシーケンス図になります。

違いとしては、

  • Vuex の場合は、Web API が頻繁に呼び出されるので、非同期処理が前提になっている
  • MVVM の場合は、Model の変更を View に伝える、同期的な通知が主になっている

ということでしょう。

Vuex で複数の Web API を呼び出す

View 側のアクションで、複数の Web API を呼び出すパターンを考えてみましょう。さきの Vuex と MVVM パターンの類似点に注目すれば、非同期的に Web API を呼び出す場所は、Actions の中になります。

この例では、api/Rooms と api/Price の2つの Web API を呼び出しています。画面では rooms と prices を独立で表示させているわけですが、画面からは1回の GetRooms を dispatch で呼び出します。その中の Actions の中で2回の Web APIが呼び出されるわけです。

View 側の次のアクションとしては、2つの Web APIが戻ってきた後(あるいは、呼び出している途中)に何かをやりたいわけですから、Actions の中で axios の await か then を使ったほうが都合がよいわけです。

もうひとつ、データを更新した上で、もう一度リストを更新させるという update と getlist の2つを順番で呼び出すパターンを考えてみましょう。

順序的には、Web API で update の API を呼び出した後に、リストの更新をしないと駄目です。何らかの原因でupdate に失敗したときはリストを更新しても意味は無いし、update が完全に終わるまでリストを取得しても意味ありません。サーバー側の update 処理完了まえにリストの取得をしても意味がありませんからね。

本来ならば、サーバー側でトランザクションを取る方法も多いのですが、呼び出し側で順番を守って Web API を呼び出しても ok な訳です。この「順番を守って呼び出す」部分を Actions 側に記述しておけば、View 側の手違いがなくなります。

Vuex はルールを守って使う

MVVMer な私としては、Vuex は「ルールを守って使えば ok」という結論です。MVVM パターンであっても、ViewModel 内で頻繁に HttpClient を非同期で呼び出すならば、同じようなシーケンスになるのならば、Vuex のスタイルに合わせたほうが無難です。

actions: 
  store.commit('project/projects', items )

getters:
  store.getters['project/projects']

dispatch:
  store.dispatch('project/projects')

mutatins:
  projects( state, payload ) {
      state.projects = payload.projects
  }

state: 
  projects: []

ただ、なんといいますか、commit や dispatch を呼び出すときに、メソッド名(タイプとも言う)を文字列になっているのはなんとかならなかったんでしょうか。ここは慣れそうにないですが、まあ、適度に map を使うということで。

カテゴリー: 開発 | MVVM パターンと Vuex を比較して理解する はコメントを受け付けていません