20200926のC#に関する記事は7件です。

D言語からC++/CLIを経由して.NET APIを利用する(旧暦、和暦処理)

はじめに

D言語から.NET APIを利用したい場合に、どうすればよいかを考えてみました。
何かの参考になればと思います。

やりたいこと

前回の記事で、D言語でのカレンダー表示プログラムを実装しました。
引き続き、旧暦カレンダーを実装してみたいと思います。
.NET APIには、JapaneseCalendarクラスやJapaneseLunisolarCalendarクラスが用意されており、これらを使えば、旧暦や和暦の計算を一から実装せずに済みそうです。

D言語から.NET API呼び出しの実現方法

私が調べた限り、C++/CLIラッピングを経由する方法が簡単だと思いました。
今回はこの方法を採用します。

※参考までに、記事を書くために調べた情報リンクを最後に掲載します。
 C#でDLLを作成し、D言語から呼び出す方法です。

前準備1(C#での実装)

D言語で実装する前に、C#で旧暦や和暦を取得する処理を実装しました。
実行時にパラメータなしだと今日の日付の旧暦や和暦、年月日パラメータを指定するとその日付の旧暦や和暦を表示します。

JCalendar.cs
using System;
using System.Globalization;

public class JCalendar {
    static string[] sEra    = // 元号
        { "", "明治", "大正", "昭和", "平成", "令和" };
    static string[] sRokuyo = // 六曜
        { "大安", "赤口", "先勝", "友引", "先負", "仏滅" };
    static string[] sKanshi = // 天干
        { "", "甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸" };
    static string[] sChishi = // 地支
        { "", "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥" };

    public static void Main(string[] args)
    {
        DateTime newDate;
        if ( args.Length >= 3 ){
            newDate = new DateTime(
                int.Parse(args[0]), int.Parse(args[1]), int.Parse(args[2]));
        } else {
            newDate = DateTime.Now;
        }
        Console.WriteLine("西暦 : {0:d}", newDate);
        if ( newDate >= new DateTime(1868, 9, 8) ){
            printJapaneseCalendar(newDate);
        }
        if ( newDate >= new DateTime(1960, 1, 28)
          && newDate <= new DateTime(2050, 1, 22) ){
            printJapaneseLunisolarCalendar(newDate);
        }
    }

    static void printJapaneseCalendar(DateTime newDate)
    {
        JapaneseCalendar jc = new JapaneseCalendar();
        int era   = jc.GetEra(newDate);
        int year  = jc.GetYear(newDate);
        int month = jc.GetMonth(newDate);
        int day   = jc.GetDayOfMonth(newDate);
        DateTime jDate  = new DateTime(year, month, day);
        Console.WriteLine("和暦 : {0} {1:d}", sEra[era], jDate);
    }

    static void printJapaneseLunisolarCalendar(DateTime newDate)
    {
        JapaneseLunisolarCalendar jlc = new JapaneseLunisolarCalendar();
        int era   = jlc.GetEra(newDate);
        int year  = jlc.GetYear(newDate);
        int month = jlc.GetMonth(newDate);
        int day   = jlc.GetDayOfMonth(newDate);

        //閏月を取得
        string sLeap = "";
        if ( year > 0 ){
            int leapMonth = jlc.GetLeapMonth(year, era);
            if ( month == leapMonth ){
                sLeap = "(閏月)";
            }
            //閏月含む場合の月を補正
            if ( (leapMonth > 0) && (month >= leapMonth) ){
                month = month - 1;  //旧暦月の補正
            }
        }
        // 干支(天干、地支)
        int sy = jlc.GetSexagenaryYear(newDate);
        int tk = jlc.GetCelestialStem(sy);
        int ts = jlc.GetTerrestrialBranch(sy);

        // 六曜(大安・赤口・先勝・友引・先負・仏滅)
        // (月 + 日) % 6 の余り
        int rokuyo = (month + day) % 6;

        Console.WriteLine("旧暦 : {0} {1:d4}/{2:d2}/{3:d2} {4}",
            sEra[era], year, month, day, sLeap);
        Console.WriteLine("干支 : {0}{1}", sKanshi[tk], sChishi[ts]);
        Console.WriteLine("六曜 : {0}", sRokuyo[rokuyo]);
    }
}

Window10には、C#コンパイラ(csc.exe)が標準インストールされています。
どのバージョンがどのフォルダにインストールされているかは、それぞれの環境に依存します。
私の環境でのコンパイル、実行例を例示します。
旧暦には、うるう月(閏月)というものが存在します。GetLeapMonthを使って、その年のうるう月を算出し、月を補正します。
実行結果では、月を補正して正しく表示できています。※旧暦カレンダーで検証
干支(天干、地支)は、その年の干支を取得しています。月や日にも干支が存在するそうです。

実行結果
d:\Dev>C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe JCalendar.cs
Microsoft (R) Visual C# Compiler version 4.8.3752.0
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.

This compiler is provided as part of the Microsoft (R) .NET Framework, but only supports language versions up to C# 5, which is no longer the latest version. For compilers that support newer versions of the C# programming language, see http://go.microsoft.com/fwlink/?LinkID=533240

d:\Dev>JCalendar
西暦 : 2020/09/26
和暦 : 令和 0002/09/26
旧暦 : 令和 0002/08/10
干支 : 庚子
六曜 : 大安

d:\Dev>JCalendar 2020 5 23
西暦 : 2020/05/23
和暦 : 令和 0002/05/23
旧暦 : 令和 0002/04/01 (閏月)
干支 : 庚子
六曜 : 仏滅

前準備2(C++/CLIでの実装)

次に、C++/CLIで旧暦や和暦を取得する処理を実装しました。
C++/CLIに関する日本語での情報は、それほど多くない印象なので、参考になればと思います。
前準備1のC#のソースコードを移植したイメージです。
実行時にパラメータなしだと今日の日付の旧暦や和暦、年月日パラメータを指定するとその日付の旧暦や和暦を表示します。

C++/CLI特有のものとして、演算子 (^)があります。

ハンドル宣言子( ^ は "hat") は、オブジェクトがアクセス不可能であるとシステムが判断したときに、宣言されたオブジェクトが自動的に削除されることを意味する型指定子をに変更します。

オブジェクト演算子 (^) へのハンドル (C++/CLI および C++/CX)より引用

また、.NET APIのクラス生成では、gcnewを使います。

マネージド型 (参照型または値型) のメモリは gcnew によって割り当てられ、ガベージ コレクションによって解放されます。

ref new、gcnew (C++/CLI および C++/CX)より引用

JCal.cpp
using namespace System;
using namespace System::Globalization;

#include <string>

String^ getEraStr(int era)
{   // 元号
    array<String^>^ sEra = gcnew array<String^>
        { "", "明治", "大正", "昭和", "平成", "令和" };
    return ( sEra[era] );
}

String^ getRokuyoStr(int rokuyo)
{   // 六曜
    array<String^>^ sRokuyo =
        { "大安", "赤口", "先勝", "友引", "先負", "仏滅" };
    return ( sRokuyo[rokuyo] );
}

String^ getKanshiStr(int kanshi)
{   // 天干
    array<String^>^ sKanshi =
        { "", "甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸" };
    return ( sKanshi[kanshi] );
}

String^ getChishiStr(int chishi)
{   // 地支
    array<String^>^sChishi =
        { "", "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥" };
    return ( sChishi[chishi] );
}

void printJapaneseCalendar(DateTime newDate)
{
    JapaneseCalendar^ jc = gcnew JapaneseCalendar();
    int era   = jc->GetEra(newDate);
    int year  = jc->GetYear(newDate);
    int month = jc->GetMonth(newDate);
    int day   = jc->GetDayOfMonth(newDate);
    DateTime jDate  = DateTime::DateTime(year, month, day);
    Console::WriteLine("和暦 : {0} {1:d}", getEraStr(era), jDate);
}

void printJapaneseLunisolarCalendar(DateTime newDate)
{
    JapaneseLunisolarCalendar^ jlc = gcnew JapaneseLunisolarCalendar();
    int era   = jlc->GetEra(newDate);
    int year  = jlc->GetYear(newDate);
    int month = jlc->GetMonth(newDate);
    int day   = jlc->GetDayOfMonth(newDate);

    //閏月を取得
    String^ sLeap = "";
    if ( year > 0 ){
        int leapMonth = jlc->GetLeapMonth(year, era);
        if ( month == leapMonth ){
            sLeap = "(閏月)";
        }
        //閏月含む場合の月を補正
        if ( (leapMonth > 0) && (month >= leapMonth) ){
            month = month - 1;  //旧暦月の補正
        }
    }
    // 干支(天干、地支)
    int sy = jlc->GetSexagenaryYear(newDate);
    int tk = jlc->GetCelestialStem(sy);
    int ts = jlc->GetTerrestrialBranch(sy);

    // 六曜(大安・赤口・先勝・友引・先負・仏滅)
    // (月 + 日) % 6 の余り
    int rokuyo = (month + day) % 6;

    Console::WriteLine("旧暦 : {0} {1:d4}/{2:d2}/{3:d2} {4}",
        getEraStr(era), year, month, day, sLeap);
    Console::WriteLine("干支 : {0}{1}", getKanshiStr(tk), getChishiStr(ts));
    Console::WriteLine("六曜 : {0}", getRokuyoStr(rokuyo));
}

int main(int argc, char* argv[])
{
    DateTime newDate = DateTime::Now;
    if ( argc > 3 ){
        newDate = DateTime::DateTime(
            atoi(argv[1]), atoi(argv[2]), atoi(argv[3]));
    }
    Console::WriteLine("西暦 : {0:d}", newDate);
    if ( newDate >= DateTime::DateTime(1868, 9, 8) ){
        printJapaneseCalendar(newDate);
    }
    if ( newDate >= DateTime::DateTime(1960, 1, 28)
      && newDate <= DateTime::DateTime(2050, 1, 22) ){
        printJapaneseLunisolarCalendar(newDate);
    }
}

私の環境には、Visual C++ ビルドツール 2019がインストールされています。
※参考:Visual C++ ビルドツール 2019 インストール手順

VS2019用 x64 Native Tools コマンドプロンプトを起動します。
共通言語ランタイム (CLR) 機能を使用するために、コンパイルオプション/clrをつけてコンパイルします。

旧暦としては、令和は3月27日から始まるようです。※旧暦カレンダーで検証

実行結果
d:\Dev>cl /clr JCal.cpp
Microsoft(R) C/C++ Optimizing Compiler Version 19.24.28314
Microsoft (R) .NET Framework の場合 バージョン 4.08.4220.0
Copyright (C) Microsoft Corporation.  All rights reserved.

JCal.cpp
Microsoft (R) Incremental Linker Version 14.24.28314.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:JCal.exe
JCal.obj

d:\Dev>JCal.exe 2019 4 30
西暦 : 2019/04/30
和暦 : 平成 0031/04/30
旧暦 : 平成 0031/03/26
干支 : 己亥
六曜 : 仏滅

d:\Dev>JCal.exe 2019 5 1
西暦 : 2019/05/01
和暦 : 令和 0001/05/01
旧暦 : 令和 0001/03/27
干支 : 己亥
六曜 : 大安

D言語、C++/CLIラッピングのソースコード

前準備が長くなりましたが、ここからが本題です。
.NET APIを呼び出すためのC++/CLIラッピング処理の実装例です。
旧暦、和暦の取得結果をstruct JCALにセットするシンプルな関数です。
D言語から呼び出せるように、extern "C" __declspec(dllexport)属性を付与しています。

JCalDll.cpp
using namespace System;
using namespace System::Globalization;

typedef struct {
    int era;
    int year;
    int month;
    int day;
    int leapMonth;
    int zodiac;
    int kanshi;
    int chishi;
    int rokuyo;
} JCAL;

#define VC_DLL_EXPORTS extern "C" __declspec(dllexport)

VC_DLL_EXPORTS void __cdecl getJapaneseCalendar(int year, int month, int day, JCAL &jcal)
{
    DateTime newDate = DateTime::DateTime(year, month, day);
    JapaneseCalendar^ jc = gcnew JapaneseCalendar();
    jcal.era   = jc->GetEra(newDate);
    jcal.year  = jc->GetYear(newDate);
    jcal.month = jc->GetMonth(newDate);
    jcal.day   = jc->GetDayOfMonth(newDate);
}

VC_DLL_EXPORTS void __cdecl getJapaneseLunisolarCalendar(int year, int month, int day, JCAL &jcal)
{
    DateTime newDate = DateTime::DateTime(year, month, day);
    JapaneseLunisolarCalendar^ jlc = gcnew JapaneseLunisolarCalendar();
    jcal.era   = jlc->GetEra(newDate);
    jcal.year  = jlc->GetYear(newDate);
    jcal.month = jlc->GetMonth(newDate);
    jcal.day   = jlc->GetDayOfMonth(newDate);
    jcal.leapMonth = 0;
    //閏月を取得
    if ( jcal.year > 0 ){
        int leapMonth = jlc->GetLeapMonth(jcal.year, jcal.era);
        if ( jcal.month == leapMonth ){
            jcal.leapMonth = 1;
        }
        //閏月含む場合の月を補正
        if ( (leapMonth > 0) && (jcal.month >= leapMonth) ){
            jcal.month = jcal.month - 1;    //旧暦月の補正
        }
    }
    // 干支(天干、地支)
    jcal.zodiac = jlc->GetSexagenaryYear(newDate);
    jcal.kanshi = jlc->GetCelestialStem(jcal.zodiac);
    jcal.chishi = jlc->GetTerrestrialBranch(jcal.zodiac);

    // 六曜(大安・赤口・先勝・友引・先負・仏滅)
    // (月 + 日) % 6 の余り
    jcal.rokuyo = (jcal.month + jcal.day) % 6;
}

次にD言語C++/CLIラッピングを呼び出し、旧暦、和暦を表示するプログラムの実装例です。
取得したい情報をstructで定義、メモリを確保しC++/CLIに渡しています。
pragma(lib, "JCalDll")extern (Windows)~を書くだけで、C++/CLI側の関数を呼び出せるので、思っていたより簡単に実装できました。

oldcal.d
import std.algorithm;
import std.conv;
import std.datetime;
import std.format;
import std.range;
import std.stdio;
import core.sys.windows.windows;

struct JCAL {
    int era;
    int year;
    int month;
    int day;
    int leapMonth;
    int zodiac;
    int kanshi;
    int chishi;
    int rokuyo;
}

pragma(lib, "JCalDll");

extern (Windows) nothrow @nogc {
    void getJapaneseCalendar(int, int, int, ref JCAL);
    void getJapaneseLunisolarCalendar(int, int, int, ref JCAL);
}

string[] sEra =
    [ "", "明治", "大正", "昭和", "平成", "令和" ];
string[] sKanshi =
    [ "", "甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸" ];
string[] sChishi =
    [ "", "子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥" ];
string[] sRokuyo =
    [ "大", "赤", "勝", "友", "負", "仏" ];
//  [ "大安", "赤口", "先勝", "友引", "先負", "仏滅" ];

void main(string[] args)
{
    Date dt = Clock.currTime().to!Date;
    if ( args.length > 2 ){
        dt = Date(args[1].to!int, args[2].to!int, 1);
    } else {
        dt = Date(dt.year, dt.month, 1);
    }
    writef("\n%4d年 %2d月", dt.year, dt.month);
    JCAL jcal;
    string line1 = "     |".cycle.take(dt.dayOfWeek * 6).to!string;
    string line2 = "      |".cycle.take(dt.dayOfWeek * 7).to!string;
    with ( jcal ){
        getJapaneseCalendar(dt.year, dt.month, 1, jcal);
        writef("(%s %2d年) ", sEra[era], year);
        getJapaneseLunisolarCalendar(dt.year, dt.month, 1, jcal);
        writefln("[%s%s]", sKanshi[kanshi], sChishi[chishi]);
        writefln("%-(  %s  |%)", ["日", "月", "火", "水", "木", "金", "土" ]);
        for ( int d = 1; d <= dt.daysInMonth; d++ ){
            getJapaneseLunisolarCalendar(dt.year, dt.month, d, jcal);
            line1 ~= format("%2d %s |", d, sRokuyo[rokuyo]);
            line2 ~= format("%2d/%2d%s|", month, day,(leapMonth == 1) ? "*" : " ");
        }
    }
    int num = (cast(int)line2.length / (7 * 7)) + 1;
    string line0 = "-".cycle.take(num * 7 * 7).to!string;
    roundRobin(line0.chunks(7 * 7), line1.chunks(6 * 7), line2.chunks(7 * 7))
        .each!(s => writefln("%s", s));
}

D言語ソースコードの補足説明

実行時にパラメータなしだと今日の年月で処理します。argsパラメータで年月を指定することも可能です。

string line1には、全角文字が各日付ごとに1文字含まれます。このため1日の表示幅が6文字となります。
新暦の日付で2文字、半角スペース1文字、六曜表示または全角スペースで1文字、半角スペース1文字、|の1文字の順で合計6文字です。

string line2はすべて半角で、1日の表示幅が7文字となります。
旧暦の年月で5文字、半角スペースまたはうるう月は*で1文字、|の1文字の順で合計7文字です。

string line0は、横線-です。

line0 line1 line2を7日ごとに区切って改行するためにchunks、交互に表示するためにroundRobinを使います。
chunks
roundRobin

コンパイル

VS2019用 x64 Native Tools コマンドプロンプトを起動します。
C++/CLIコンパイル時に、オプション/clr/LDを付けます。JCalDll.libJCalDll.dllが生成されます。
JCalDll.libは、D言語ソースのコンパイル時に必要です。JCalDll.dllは、oldcal.exe実行時に必要です。

dmdでのコンパイル時は、64bitコード生成のためにオプション-m64をつけます。
ldc2をインストールしていれば、ldc2 oldcal.dでもOKです。

コンパイル
d:\Dev>cl /clr /LD JCalDll.cpp
Microsoft(R) C/C++ Optimizing Compiler Version 19.24.28314
Microsoft (R) .NET Framework の場合 バージョン 4.08.4220.0
Copyright (C) Microsoft Corporation.  All rights reserved.

JCalDll.cpp
Microsoft (R) Incremental Linker Version 14.24.28314.0
Copyright (C) Microsoft Corporation.  All rights reserved.

/out:JCalDll.dll
/dll
/implib:JCalDll.lib
JCalDll.obj
   ライブラリ JCalDll.lib とオブジェクト JCalDll.exp を作成中

d:\Dev>dmd -m64 oldcal.d

実行結果

表示される日本語の文字コードはUTF-8なので、先にchcp 65001を実行してください。
Shift-JISで日本語出力したい場合は、以前紹介した通り、文字コード変換処理をソースコードに加える必要があります。
紹介情報1
紹介情報2

私の環境のコマンドプロンプトでは、Cicaフォントを使っています。
このため、実行結果では全角スペースが四角の枠で表示されています。

これで、自分の誕生月の旧暦カレンダーも見られそうです。
ただし、JapaneseLunisolarCalendarクラスのGetLeapMonthの仕様で、西暦1960年1月27(旧暦の1959年)以前のうるう月を取得しようとするとExceptionが発生するため、西暦1960年2月以降の旧暦カレンダーしか表示できません。上限は西暦2049年12月です。
無題.png

今回使用した参考情報

JapaneseCalendarクラス
JapaneseLunisolarCalendarクラス
オブジェクト演算子 (^) へのハンドル (C++/CLI および C++/CX)
ref new、gcnew (C++/CLI および C++/CX)

C#のメソッドをC++から呼ぶ方法
C#をC++/CLIでラップしてC++アプリから呼ぶ

六曜・月齢・旧暦カレンダー
年・月・日の干支
六曜はどのように決まるのか?六曜の計算方法は?
c#で六曜の計算

今回使用していない情報だけど参考になりそうな情報

C++/CLIラッピング入門
C++のモジュールからC#のDLLを呼び出してみる
C++からC# DLLを直接利用する方法

Calling-NET-from-D

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

C#でMySQLを使う - 3.INSERT追加・UPDATE更新・DELETE削除してみる

前提と準備

C#の記事

前回はVisual Studio 2019にMySQL Connectors .NETを用いて、とりあえずMySQLに接続して、SELECTを用いてデータの内容を画面に表示しました。今回はDBのデータそのものを変更を施します(˶ ・ᴗ・ )੭

環境

  • OS:Windows 10 Pro
  • 開発環境:Visual Studio 2019 (MySQL Connectors .NET利用)
  • データベース:MySQL 5.7

MySQLサーバー

テスト用ユーザー:test (パスワード:test1)
データベース:manutest
テーブル名:testtb

id name memo
INT VARCHAR(64) VARCHAR(256)
必須属性 PRIMARY KEY NOT NULL 初期値NULL
その他属性 AUTO_INCREMENT - -

前提

前回のように、Visual Studio 2019とMySQLをインストールし、かつMySQL Connectors .NETがインストールされていること(Visual Studioは自動でMySQL Connectorsのライブラリを認識してくれる)

作業手順

Visual Studio 2019でのコーディング

コンポーネント・コントロールの配置

今回はボタンを押下→MySQLでデータを拾ってくる→画面に表示
これに加えて、名前とメモを入力し、追加や更新などができるようにフォームを追加
右上のリストボックスを選択すると該当するIDが画面表示されるので、選択している際に該当IDの更新や削除ができるようになりますが、細かいUI制御までは触れません

WindowsのC#フォームのプロジェクトを新規作成し、Form1.csデフォルトで開きました

フォームへのコンポーネント配置

  • 左上:データグリッド dataGridView1
  • 右上:リストボックス2つ listBox1・listBox2
    • 選択するとテキストボックス「idNum」に該当するIDを表示する
  • 左下:SQLを読み込むボタン button1
  • 右中央:名前入力 textBoxName・メモ入力 textBoxMemo
  • 右下:選択中のID idNum・新規追加SQL実行 buttonAdd・更新SQL実行 buttonEdit・削除SQL実行 buttonDel

これらを貼り付けます。
データグリッドとリストボックスはSQLのデータを表示させるために配置しました

MySQL Connector .NETの参照を追加

MySQLインストール時と同じように、参照を追加していきます。

MySql.Data

「アセンブリ」の中に実際自動で認識してくれるので、参照マネージャーで、右上の検索画面に「mysql」を入力すると、↑の画面のように「MySql.Data」が何行も出てくるので、とりあえず1つだけ、どれでもいいので選択して(行左にマウスを当てると出てくるチェックボックスをON)「OK」を選択すると…

MySQLを使う

MySql.Data」が追加されました(*˘꒳˘*)
この中にMySQLを扱うC#オブジェクトが入っているのです。

ボタンクリック時のソースを記述する

今回も簡単のため、SQLの実行はボタンクリックで読み込むだけで、Form1.csのクリックイベント動作のみをコーディングしました。本来は規模が大きくなることがほとんどなので、formのボタンクリックではなく、独立したクラスに分けるケースがほとんどですが。。。

Form1.cs サンプルコード
Form1.cs
using System;
using System.Collections.Generic;
using System.Data;
using System.Windows.Forms;
using MySql.Data.MySqlClient;

namespace MySqlFormsTest
{
    public partial class Form1 : Form
    {
        // 取得したデータのID一覧(フォーム内部変数)
        private List<int> idNums;

        // 選択中のID
        private int selId;

        // MySQL接続情報
        private string connStr = "server=127.0.0.1;user id=test;password=test1;database=manutest";

        public Form1()
        {
            InitializeComponent();
        }

        /**
         * 接続ボタン(MySQLサーバーに接続し、一覧データを取得する)
         * */
        private void button1_Click(object sender, EventArgs e)
        {
            MySqlConnection conn = new MySqlConnection(this.connStr);

            // 画面と内部変数を初期化
            listBox1.Items.Clear();
            listBox2.Items.Clear();
            this.idNums = new List<int>();
            selId = -1;
            idNum.Text = "";
            buttonEdit.Enabled = false;
            buttonDel.Enabled = false;

            try
            {
                // 接続を開く
                conn.Open();

                // データを取得するテーブル
                DataTable tbl = new DataTable();

                // SQLを実行する
                MySqlDataAdapter dataAdp = new MySqlDataAdapter("SELECT id, name, memo FROM testtb", conn);
                dataAdp.Fill(tbl);

                // dataGridViewに表示させる
                dataGridView1.DataSource = tbl;

                // 実行結果を1行ずつ参照する場合
                for (int i = 0; i < tbl.Rows.Count; i++)
                {
                    DataRow row = tbl.Rows[i];  // データ行

                    // 右のリストボックスにアイテムを追加
                    listBox1.Items.Add(row[1]);
                    listBox2.Items.Add(row[2]);
                    // 同時にIDを内部変数に追加
                    this.idNums.Add((int)row[0]);
                }

                // 接続を閉じる
                conn.Close();
            }
            catch (MySqlException mse)
            {
                MessageBox.Show(mse.Message, "データ取得エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

        /**
         * リストボックスの行を選択
         */
        private void listBox1_SelectedIndexChanged(object sender, EventArgs e)
        {
            this.listBox_SelectedIndexChanged(sender, e);

            // 選択したIDを画面表示
            if(listBox1.SelectedIndex != -1)
            {
                this.selId = idNums[listBox1.SelectedIndex];
                idNum.Text = idNums[listBox1.SelectedIndex].ToString();
            }
            else
            {
                this.selId = -1;
                idNum.Text = "";
            }
        }
        private void listBox2_SelectedIndexChanged(object sender, EventArgs e)
        {
            this.listBox_SelectedIndexChanged(sender, e);

            // 選択したIDを画面表示
            if (listBox2.SelectedIndex != -1)
            {
                this.selId = idNums[listBox2.SelectedIndex];
                idNum.Text = idNums[listBox2.SelectedIndex].ToString();
            }
            else
            {
                this.selId = -1;
                idNum.Text = "";
            }
        }
        private void listBox_SelectedIndexChanged(object sender, EventArgs e)
        {
            // どちらかを選択していないと追加と削除が使えない
            if(listBox1.SelectedIndex != -1 || listBox2.SelectedIndex != -1)
            {
                buttonEdit.Enabled = true;
                buttonDel.Enabled = true;
            }
            else
            {
                buttonEdit.Enabled = false;
                buttonDel.Enabled = false;
            }
        }


        /**
         * 新規追加ボタンを選択
         */
        private void buttonAdd_Click(object sender, EventArgs e)
        {
            MySqlConnection conn = new MySqlConnection(this.connStr);
            MySqlTransaction trans = null;          // 実行トランザクション

            // 新規追加のSQLコマンド
            string sqlCmd = @"INSERT INTO testtb (name, memo) VALUES (@name, @memo)";

            // 追加クエリの開始
            MySqlCommand cmd = new MySqlCommand(sqlCmd, conn);

            try
            {
                // ステークホルダーのセット
                cmd.Parameters.AddWithValue("name", textBoxName.Text);
                cmd.Parameters.AddWithValue("memo", textBoxMemo.Text);

                cmd.Connection.Open();              // 接続を開く

                // トランザクション監視開始
                trans = cmd.Connection.BeginTransaction(IsolationLevel.ReadCommitted);

                // SQL実行
                cmd.ExecuteNonQuery();

                // DBをコミット
                trans.Commit();
            }
            catch (MySqlException mse)
            {
                trans.Rollback();                   // 例外発生時はロールバック
                MessageBox.Show(mse.Message, "データ追加エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            finally
            {
                // 接続はクローズする
                cmd.Connection.Close();
            }
        }

        /**
         * 編集ボタンを選択
         */
        private void buttonEdit_Click(object sender, EventArgs e)
        {
            MySqlConnection conn = new MySqlConnection(this.connStr);
            MySqlTransaction trans = null;          // 実行トランザクション

            // 編集のSQLコマンド
            string sqlCmd = @"UPDATE testtb SET name = @name, memo = @memo WHERE id = @id";

            // 編集クエリの開始
            MySqlCommand cmd = new MySqlCommand(sqlCmd, conn);

            try
            {
                // 選択中のIDを用いて、ステークホルダーのセット
                cmd.Parameters.AddWithValue("id", this.selId);
                cmd.Parameters.AddWithValue("name", textBoxName.Text);
                cmd.Parameters.AddWithValue("memo", textBoxMemo.Text);

                cmd.Connection.Open();              // 接続を開く

                // トランザクション監視開始
                trans = cmd.Connection.BeginTransaction(IsolationLevel.ReadCommitted);

                // SQL実行
                cmd.ExecuteNonQuery();

                // DBをコミット
                trans.Commit();
            }
            catch (MySqlException mse)
            {
                trans.Rollback();                   // 例外発生時はロールバック
                MessageBox.Show(mse.Message, "データ更新エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            finally
            {
                // 接続はクローズする
                cmd.Connection.Close();
            }
        }

        /**
         * 削除ボタンを選択
         */
        private void buttonDel_Click(object sender, EventArgs e)
        {
            MySqlConnection conn = new MySqlConnection(this.connStr);
            MySqlTransaction trans = null;          // 実行トランザクション

            // 削除のSQLコマンド
            string sqlCmd = @"DELETE FROM testtb WHERE id = @id";

            // 削除クエリの開始
            MySqlCommand cmd = new MySqlCommand(sqlCmd, conn);

            try
            {
                // 選択中のIDを用いて、ステークホルダーのセット
                cmd.Parameters.AddWithValue("id", this.selId);

                cmd.Connection.Open();              // 接続を開く

                // トランザクション監視開始
                trans = cmd.Connection.BeginTransaction(IsolationLevel.ReadCommitted);

                // SQL実行
                cmd.ExecuteNonQuery();

                // DBをコミット
                trans.Commit();
            }
            catch (MySqlException mse)
            {
                trans.Rollback();                   // 例外発生時はロールバック
                MessageBox.Show(mse.Message, "データ削除エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            finally
            {
                // 接続はクローズする
                cmd.Connection.Close();
            }
        }
    }
}

まずフォームのC#ソースの内部変数として、MySQLのデータでどのIDを取得したかと、右上のリストボックスの項目に対応するIDはどれを選択しているのかを把握するため、Form1にprivate変数を追加しました

// 取得したデータのID一覧(フォーム内部変数)
private List<int> idNums;

// 選択中のID
private int selId;

読み込むSQLを実行するボタンで「SELECT id, name, memo~」をbutton1_Click()内で実行していると思いますが、単に右上のリストボックスにピックアップするだけでなく、対応するIDを内部変数idNumsで受け持つコードも実装しました

// 実行結果を1行ずつ参照する場合
for (int i = 0; i < tbl.Rows.Count; i++)
{
    DataRow row = tbl.Rows[i];  // データ行

    // 右のリストボックスにアイテムを追加
    listBox1.Items.Add(row[1]);
    listBox2.Items.Add(row[2]);
    // 同時にIDを内部変数に追加
    this.idNums.Add((int)row[0]);
}

row[0]が取得したデータのID番号ですが、そのままListにAdd()を実行するとObjectを暗黙的に変換できないエラーとなるため、(int)でキャストしました。

listBox1.SelectedIndex
listBox2.SelectedIndex

listBox1_SelectedIndexChanged()などで使っていますが、リストボックスの選択したインデックス番号(0で始まるもの)はこれを使っています(未選択の場合は-1)。選択するとidNumの選択中IDのフィールドに表示する仕組みを入れたんです。選択中のIDは内部変数に入れています。

this.selId = idNums[listBox1.SelectedIndex];

INSERTやUPDATEなどDBの内容を更新するSQLは、MySqlCommandを使ったんですが、こっちのほうがプレースホルダーが使いやすいので、SELECT以外はMySqlCommandを使いました。

string sqlCmd = @"INSERT INTO testtb (name, memo) VALUES (@name, @memo)";
MySqlCommand cmd = new MySqlCommand(sqlCmd, conn);

そして更新の際はトランザクションを使っています。例外が発生したときはいつもロールバックするようにしていますが、正直タイミングは考えるのは大変なので、今回は簡単で例外キャッチのみで;;

try
{
    trans = cmd.Connection.BeginTransaction(IsolationLevel.ReadCommitted);
    cmd.ExecuteNonQuery();
    trans.Commit();
}
catch (MySqlException mse)
{
    trans.Rollback();                   // 例外発生時はロールバック
}

実行結果

今回は追加などのボタンに再読み込み機能がないので、追加などのボタンを押した後は「読み込み」ボタンを押して再表示させています(面倒ですが…)

最初に下の画像のようなデータがあるとして、IDが6のデータを更新すると…
編集
編集
「更新」→「読み込み」で、更新に成功しました(˶ ・ᴗ・ )੭

次はIDが5の「Testing」というデータを選択して消してみます
削除
削除
再度読み込みボタンを押して、これも削除成功しました!!

最後に追加してみます
追加
追加
見事成功しました(˶ ・ᴗ・ )੭!!

次回

Visual StudioでMySQLのデータがNULLの場合にハマったことがあるので、そっちについて追ってみます

参考文献

  1. 【Visual Studio】C#からMySqlの使い方 接続するには?SQL文を実行するには? - 困ったー
  2. C#でMySQLからSELECTした結果を取り出したい

関連文献

  1. C#のMySQLでトランザクション使用する / C#でのトランザクションの実装について
  2. Symfoware Server アプリケーション開発ガイド(埋込みSQL編) - FUJITSU - / SQLのトランザクションとは何かの理解
  3. 【MySQL 5.1向けのもの】MySqlTransaction の使用 / C#でのトランザクション実装例
  4. IsolationLevel 列挙型 / C#でのトランザクションレベル - .NET API
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Azure Static Web Apps + Communication Services + Blazor + Functions してみた

とりまやってみましょう。
Azure Communication Services は Azure 上に必須なので作ります。

Token の払い出しを行うサーバーサイドの作成

これは Functions で行います。こちらにある通り。

Azure Functions を使用して、信頼できる認証サービスを構築する

今回は以下のような感じでいこうかなと思います。URL のクエリに id という名前のパラメーターがあるときは、それをユーザー ID としてトークンを取得。id 指定がない場合はユーザーを作成してトークンを取得といった感じにします。

id は、適当な値を設定しても Azure Communication Services ではじかれるので、初回は id 無しで呼び出して 2 回目以降は id 指定で行く感じです。戻ってくる JSON はこんな感じで

戻り値
{
  "token": "xxxxx",
  "expiresOn": "2020-09-27T04:36:01.1941722+00:00",
  "userId": "xxxxxxxxxx"
}

Azure Functions のプロジェクトを作って local.settings.json に Azure Communication Services の接続文字列を追加します。

local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AzureCommunicationServices": "endpoint=https://xxxx.communication.azure.com/;accesskey=xxxxxxxxx"
  }
}

Microsoft.Azure.Functions.ExtensionsAzure.Communication.Administration (preview) パッケージを追加して以下のように Startup.cs を作ります。

Startup.cs
using Azure.Communication.Administration;
using Microsoft.Azure.Functions.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SimpleChat.Server;
using System;

[assembly: FunctionsStartup(typeof(Startup))]

namespace SimpleChat.Server
{
    public class Startup : FunctionsStartup
    {
        public override void Configure(IFunctionsHostBuilder builder)
        {
            builder.Services.AddTransient(provider =>
              {
                  var c = provider.GetRequiredService<IConfiguration>();
                  return new CommunicationIdentityClient(c.GetValue<string>("AzureCommunicationServices"));
              });
        }
    }
}

では、Token を作成する関数を作ります。HttpTrigger の関数で CommunicationIdentityClient を使ってトークンを生成して返します。まずは、戻り値のクラスを定義して…

GetTokenResponse.cs
using System;

namespace SimpleChat.Core
{
    public class GetTokenResponse
    {
        public string Token { get; set; }
        public DateTimeOffset ExpiresOn { get; set; }
        public string UserId { get; set; }
    }
}

関数本体を実装します。

GetToken.cs
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Azure.Communication.Administration;
using SimpleChat.Core;
using Azure.Communication;

namespace SimpleChat.Server
{
    public class GetToken
    {
        private readonly CommunicationIdentityClient _communicationIdentityClient;

        public GetToken(CommunicationIdentityClient communicationIdentityClient)
        {
            _communicationIdentityClient = communicationIdentityClient ?? throw new ArgumentNullException(nameof(communicationIdentityClient));
        }

        [FunctionName(nameof(GetToken))]
        public  async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req,
            ILogger log)
        {
            var id = req.Query["id"].ToString();

            var user = string.IsNullOrWhiteSpace(id) ?
                (await _communicationIdentityClient.CreateUserAsync()).Value :
                new CommunicationUser(id);

            try
            {
                var tokenResponse = await _communicationIdentityClient.IssueTokenAsync(user, new[] { CommunicationTokenScope.Chat });
                return new OkObjectResult(new GetTokenResponse
                {
                    UserId = tokenResponse.Value.User.Id,
                    Token = tokenResponse.Value.Token,
                    ExpiresOn = tokenResponse.Value.ExpiresOn,
                });
            }
            catch (Exception ex)
            {
                log.LogError(ex, "IssureTokenAsync");
                return new BadRequestResult();
            }
        }
    }
}

次に人が集まって会話するスレッドを作成する関数を追加します。このスレッド ID が一緒じゃないと一人チャットになるので、スレッド ID は皆で共有できないといけません。
実は先ほど作ったユーザーの ID も、システムで管理してる(もしくは外部の ID プロバイダーの)ユーザーとの紐づけの管理とかは自分で作りこまないといけません。

今回はユーザー管理は毎回ユーザーを作るという手抜き実装ですがスレッド ID は共有しないとみんなでチャット出来ないので頑張って作ります。ただ、 Cosmos DB とかに保存する処理とかを作るのはだるいのでメモリ上に保持するようにします。なので、Azure Functions がスケール アウトやスケール インすると不都合が起きますが、ちゃんと DB を使えばうまくいくはずです。

今回はグローバルで 1 つのスレッドという感じになるべくなるように実装していきます。

Server のプロジェクトに Azure.Communication.Chat パッケージを追加して GetToken メソッドでスレッドが無ければ作るように変更します。

local.settings.json にスレッド ID を作るために必要なエンドポイントの設定も追加します。

local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet",
    "AzureCommunicationServices": "endpoint=https://xxxxxx.communication.azure.com/;accesskey=xxxxx...",
    "AzureCommunicationServicesEndpoint": "https://xxxxxx.communication.azure.com/"
  }
}

そして、GetToken.cs にスレッドも作る処理を追加しておきます。

GetToken.cs
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Azure.Communication.Administration;
using SimpleChat.Core;
using Azure.Communication;
using Azure.Communication.Chat;
using Microsoft.Extensions.Configuration;
using Azure.Communication.Identity;

namespace SimpleChat.Server
{
    public class GetToken
    {
        // 危険!こんなことは本番でやったらだめ!!絶対!!だけど、とりあえずのお試しなので static 変数にスレッドクライアントを保持します
        private static ChatThreadClient ChatThreadClient { get; set; }
        // ここにエンドポイントの値を構成情報から設定する
        private readonly string _endpoint;
        private readonly CommunicationIdentityClient _communicationIdentityClient;

        public GetToken(CommunicationIdentityClient communicationIdentityClient, IConfiguration configuration)
        {
            _communicationIdentityClient = communicationIdentityClient ?? throw new ArgumentNullException(nameof(communicationIdentityClient));
            _endpoint = configuration.GetValue<string>("AzureCommunicationServicesEndpoint");
        }

        [FunctionName(nameof(GetToken))]
        public  async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequest req,
            ILogger log)
        {
            var id = req.Query["id"].ToString();

            var user = string.IsNullOrWhiteSpace(id) ?
                (await _communicationIdentityClient.CreateUserAsync()).Value :
                new CommunicationUser(id);

            try
            {
                var tokenResponse = await _communicationIdentityClient.IssueTokenAsync(user, new[] { CommunicationTokenScope.Chat });

                // スレッド ID がなかったら作る。同時アクセスがあると死ぬロジックなので本番でマネしないでね!
                var chat = new ChatClient(
                    new Uri(_endpoint),
                    new CommunicationUserCredential(tokenResponse.Value.Token));
                if (ChatThreadClient == null)
                {
                    // Thread が無い場合
                    ChatThreadClient = await chat.CreateChatThreadAsync(
                        "talk",
                        new[] { new ChatThreadMember(user) });
                }
                else
                {
                    // Thread がある場合は参加しておく
                    await ChatThreadClient.AddMembersAsync(new[] { new ChatThreadMember(user) });
                }

                return new OkObjectResult(new GetTokenResponse
                {
                    UserId = tokenResponse.Value.User.Id,
                    Token = tokenResponse.Value.Token,
                    ExpiresOn = tokenResponse.Value.ExpiresOn,
                    ThreadId = ChatThreadClient.Id, // ThreadId も返す
                });
            }
            catch (Exception ex)
            {
                log.LogError(ex, "IssureTokenAsync");
                return new BadRequestResult();
            }
        }
    }
}

こうすると GetToken 関数の結果に threadId が追加されます。

GetTokenの結果
{
  "token": "xxx.....",
  "expiresOn": "2020-09-27T06:04:49.1221965+00:00",
  "userId": "xxxxx...",
  "threadId": "xxxx...."
}

最後にローカルデバッグ時用の CORS の設定を追加します。local.settings.json に CORS の設定を以下のように追加しておきます。

local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    ... 省略 ...
  },
  "Host": {
    "CORS": "*"
  }
}

クライアント側の実装

次にクライアント側です。Blazor のプロジェクトを作りましょう。そして Azure.Communication.Chat (preview) パッケージを追加します。

とりあえずローカル実行時は、Azure Functions のローカルサーバーを見に行くようにします。Program.cs を以下のようにします。

Program.cs
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleChat.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("app");

            builder.Services.AddScoped(sp => new HttpClient 
            { 
                BaseAddress = new Uri(builder.Configuration["BaseAddress"] ?? builder.HostEnvironment.BaseAddress) 
            });

            await builder.Build().RunAsync();
        }
    }
}

wwwroot フォルダーの下に appsettings.Development.json という名前で以下のファイルを置いたら構成は完了です。

appsettings.Development.json
{
  "BaseAddress": "http://localhost:7071/"
}

チャットにつなごう

ではつないでいきます。繋ぐためにはエンドポイントが必要なのでクライアントの構成ファイルに書いておくようにします。

appsettings.Development.json に以下のように設定を追加します。

appsettings.Development.json
{
  "BaseAddress": "http://localhost:7071/",
  "CommunicationServiceSettings": "https://xxxxxx.communication.azure.com/"
}

追加した設定を保持するためのクラスを定義します。

CommunicationServiceSettings.cs
namespace SimpleChat.Client
{
    public class CommunicationServiceSettings
    {
        public string Endpoint { get; set; }
    }
}

そして Program.cs に、設定をクラスに読み込んでおく処理を追加します。

Program.cs
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using SimpleChat.Client.Services;
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading.Tasks;

namespace SimpleChat.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("app");

            builder.Services.AddScoped(sp => new HttpClient 
            { 
                BaseAddress = new Uri(builder.Configuration["BaseAddress"] ?? builder.HostEnvironment.BaseAddress) 
            });

            // 設定を読み込み
            builder.Services.AddSingleton(sp =>
            {
                // Configure メソッド使いたかったけど何回試しても動かなかったので泣く泣く…
                var c = sp.GetRequiredService<IConfiguration>();
                return new CommunicationServiceSettings
                {
                    Endpoint = c[nameof(CommunicationServiceSettings)]
                };
            });
            builder.Services.AddScoped<CommunicationService>();

            await builder.Build().RunAsync();
        }
    }
}

そして、チャットのやり取りをするクラスを定義します。ここらへんは ChatClient や ChatThreadClient を使ってサクッと作れます。

CommunicationService.cs
using Azure.Communication.Chat;
using Azure.Communication.Identity;
using Microsoft.Extensions.Options;
using SimpleChat.Core;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

namespace SimpleChat.Client.Services
{
    public class CommunicationService
    {
        private static string ThreadId => "hello-world-sample";
        private readonly CommunicationServiceSettings _communicationServiceSettings;
        private readonly HttpClient _http;
        private GetTokenResponse _getTokenResponse;
        private ChatClient _chatClient;
        private ChatThreadClient _chatThreadClient;
        public CommunicationService(IOptions<CommunicationServiceSettings> settings, HttpClient http)
        {
            _communicationServiceSettings = settings.Value;
            _http = http;
        }

        public bool IsJoined => _chatThreadClient != null;

        // チャットに参加
        public async ValueTask JoinToChatAsync()
        {
            var res = await _http.GetStringAsync("/api/GetToken");
            _getTokenResponse = JsonSerializer.Deserialize<GetTokenResponse>(res);

            _chatClient = new ChatClient(
                new Uri(_communicationServiceSettings.Endpoint), 
                new CommunicationUserCredential(_getTokenResponse.Token));
            _chatThreadClient =  _chatClient.GetChatThreadClient(_getTokenResponse.ThreadId);
        }

        // チャットにメッセージを送信
        public async ValueTask SendMessageAsync(string name, string message)
        {
            await _chatThreadClient.SendMessageAsync(message, senderDisplayName: name);
        }

        // チャットのメッセージを取得
        public IAsyncEnumerable<ChatMessage> GetMessagesAsync() => _chatThreadClient.GetMessagesAsync();
    }
}

Program.cs にコンテナに登録する処理も追加しておきましょう。

Program.cs
builder.Services.AddTransient<CommunicationService>();

仕上げに Index.razor です。async foreach で脳死で読み込めばいい感じにメッセージがくるたびに読み込んでくれるかと思ったら、ダメだったので泣く泣くメッセージの重複管理を自前でやってます。これチャットが長くなると毎回データとってくるのつらいのでは…。そのうち API がいい感じになるのかな??

Index.razor
@page "/"
@using SimpleChat.Client.Services
@using System.Diagnostics
@using Azure.Communication.Chat
@inject CommunicationService communicationService
@inject Microsoft.Extensions.Configuration.IConfiguration configuration;

<h1>Simple chat</h1>

@if (communicationService.IsJoined)
{
    <div>
        ユーザー名:@displayName
    </div>
    <div>
        <input type="text" @bind-value="message" />
        <button @onclick="SendButton_Click" disabled="@(string.IsNullOrEmpty(message))">Send</button>
        <hr />
        <ul>
            @foreach (var message in messages)
            {
                <li @key="message.Id">@message.SenderDisplayName : @message.Content</li>
            }
        </ul>
    </div>
}
else
{
    <button @onclick="JoinButton_Click" disabled="@(string.IsNullOrEmpty(displayName))">Join</button>
    <input type="text" @bind-value="displayName" />
}

@code {
    private readonly HashSet<string> messageIds = new HashSet<string>();
    private readonly List<ChatMessage> messages = new List<ChatMessage>();
    private string displayName;
    private string message;
    private async void JoinButton_Click()
    {
        await communicationService.JoinToChatAsync();
        StateHasChanged();
        _ = StartReadMessagesAsync();
    }

    private async ValueTask StartReadMessagesAsync()
    {
        while (true)
        {
            await foreach (var message in communicationService.GetMessagesAsync())
            {
                Debug.WriteLine($"{message.Type}: {message.SenderDisplayName} > {message.Content}");
                if (message.Type == "Text")
                {
                    Debug.WriteLine("Text!!");
                    if (!messageIds.Contains(message.Id))
                    {
                        messages.Add(message);
                        messageIds.Add(message.Id);
                    }
                }
            }

            StateHasChanged();
            await Task.Delay(3000);
        }
    }

    private async void SendButton_Click()
    {
        await communicationService.SendMessageAsync(displayName, message);
        message = "";
        StateHasChanged();
    }
}

ローカルで動かしてみましょう。起動直後

image.png

名前を入力したところ(複数ブラウザで試してみてる)

image.png

メッセージうってみた

image.png

Static Web Apps へデプロイ

GitHub にリポジトリを作ってお約束の手順でデプロイしてみました。今回はこんな感じのレイアウトなので

image.png

それにあわせて、こんな感じで作りました

image.png

デプロイが完了したら構成に Azure Communication Services 関連の設定を追加しておきます。

image.png

クライアント側にも appsettings.Development.json しかなかったので appsettings.json を足しておきます。Azure Communication Services 関連の設定だけ引っ越ししましょう。以下のように

appsettings.json
{
  "CommunicationServiceSettings": "https://xxxxxxx.communication.azure.com/"
}

Static Web Apps にデプロイしてもとりあえずは動きました。

image.png

まとめ

ということで Azure Communication Services で一番とっつきやすいチャット機能を Blazor + Functions で試してみました。デプロイ先は Static Web Apps です。

試してみた感じ、Azure Communication Services は ID は発行してくれるけど、それが実際に誰なのかということは自分で管理しないといけないなという感じでした。あと、ちゃんと作るときは Azure AD や Azure AD B2C か、それに準ずる何かしらでログイン機能も実装しておかないとフリーダムなチャットになりそうだなと思いました。
後は誰がどんなスレッドを持ってるのかとかも、おそらくアプリ側で管理することになる気がします。あくまで Communication Services が提供するのは作成と ID 指定での検索だけっぽいです。
なのでちゃんと作るとしたら最低でも以下のような雰囲気になる気がします。

image.png

仕様なのかバグなのかわかりませんが C# SDK は今のところポーリングしてメッセージとってくるのですが、毎回メッセージが全部返ってくるので、誰か差分だけいい感じにとってくる方法を教えてください。

startTime 引数と AsPages で continueousToken を指定しても、全部返ってきました orz

音声チャットも試してみたいな。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】Reflectionでジェネリックを扱う方法

はじめに

C#でReflectionを使ったことがありますか?
Reflectionを使うことで今まではできなかったような処理を書くことができます
その一つとしてgenericのクラスの呼び出し方法を今回はご紹介します。

Typeを取得する

普通に取得する方法

呼び出したいインスタンス.GetType();
Type.GetType("genericの型`引数の型の数[[型],[型].....]"):
typeof(genericの型<>);

注意点としては文字列で<>ではなく[]にする必要があり、ジェネリックの数が`の後に必要です

通常ライブラリを使うだけならなにも問題はありません
しかし、動的コード生成する場合や動的にDLLを読み込むときに使用する場合は問題が発生します。

動的な読み込みの際に取得する方法

動的にDLL等を読み込むときは上記の方法ではできません
なので別な方法でする必要があります。
これは他のクラスを取得する方法と同じだと思っていました
しかしそれではダメだったのでご紹介します

ダメだった方法

呼び出したいアセンブリ型のインスタンス.GetType("genericの型`引数の型の数[[型],[型].....]"):

この方法では他の型を読み込んでくれないためだめでした。

つまずいた方法

調べているとMakeGenericTypeメソッドでgenericの型の引数を任意に決めれると書いてあったのでやってみました。

Type t = 呼び出したいアセンブリ型のインスタンス.GetType("genericの型`引数の型の数"):
//ジェネリック自体の型を取得
t.MakeGenericType(指定したい型のType);

しかし問題がこれを書いてブレークポイントで確認してみましたが
tの中は変わっていませんでした。

実はMakeGenericTypeは適応したgeneric型のtypeを新しく作るメソッドでした

つまり...

正しい方法

Type t1 = 呼び出したいアセンブリ型のインスタンス.GetType("genericの型`引数の型の数"):
//ジェネリック自体の型を取得
Type t2 = t.MakeGenericType(指定したい型のType);

このようにすることで動的に読み込んだgeneric型のTypeを取得することができます。
MakeGenericTypeメソッドは新しく作ったType型を戻り値として出すメソッドですので
ちゃんと代入しないとだめですよね

まとめ

まとめとしてはちゃんとメソッドの仕様を調べて使うことにしましょう。
でないと、私みたいに痛い目を見ます。
皆様お気を付けて

この辺りで失礼します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[ASP.NET Core] HTMLヘルパーを使ってリスト形式の入力画面を作るときの注意

HTMLヘルパーを入力画面かつList型のクラスで使うときの注意

ASP.NET Core (MVC)でHTMLヘルパーを使って、テーブルタグで複数行入力し、List型のクラスでポストデータを受け取る画面を作ろうとしたら、入力データがポストされずハマったのでメモ

結論はforeachじゃなくて普通のforでやればOK

NG

foreachだとpostされてこないよ

@foreach (var row in Model.Rows)
{
<tr>
    <td>@Html.EditorFor(m => row.ColA)</td>
    <td>@Html.EditorFor(m => row.ColB)</td>
</tr>
}

OK

forだとOK

@for(var i = 0; i < Model.Rows.Count; i++)
{
<tr>
    <td>@Html.EditorFor(m => m.Rows[i].ColA)</td>
    <td>@Html.EditorFor(m => m.Rows[i].ColB)</td>
</tr>
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[.NET][C#]Entity Framework Coreを使用してDBアクセス・ORマッピング

はじめに

急遽来月からC#でシステム立ち上げの案件に入ることになり、
最近の.NET周りの技術について勉強することに。

以前関わっていたプロジェクトでは、
ASP.Net MVCで構築し、その際ORマッパーなどは使用していなかったが、
.NET Core勉強中にふとEntity Framework CoreというORマッパーが目についたので
触ってみたところ、とても良かったので記事にした次第です。

今回は1つのテーブルからデータを取得する処理を
Entity Framework Coreを使用してやってみました。

環境

言語:C# 8.0
FW:.NET Core 3.1.401
DB:MySQL 8.0.21
その他:Docker, Adminerなど

事前準備

1. Entity Framework CoreのNuGetパッケージをインストール

EFCoreのパッケージをインストールします。
今回はDBにMySQLを使うので、.NETプロジェクト直下で以下コマンドを実行。

dotnet add package MySql.Data.EntityFrameworkCore -v 8.0.21

NuGetパッケージ名は以下サイトに記載してあります。
有名どころのDBは大体あると思う。
https://docs.microsoft.com/ja-jp/ef/core/providers/?tabs=dotnet-core-cli

2. DB構築

今回は以下のような企業の株価を保持するテーブルを準備します。
スクリーンショット 2020-09-26 115137_2.png
とりあえず上記テーブルに2020/9/1時点の東証1部の会社の株価データレコードを挿入します。
本当は時価総額が良かったけどデータが見つからなかった。

実装

1. Entityクラスの作成

テーブルのレコードを保持するいわゆるエンティティクラスを作ります。

    public class CompanyData {
        [Key]
        public string StockCode {get; set;}
        public string CompanyName {get; set;}
        public string IndustryCode {get; set;}
        public int StockPrice {get; set;}
    }

プロパティは、テーブルのカラム名・型を一致させる必要があります。
また、基本的にEntityクラスには主キーのプロパティが必要で、
これが無いとクエリ発行時に例外が投げられます。
主キーにあたるプロパティに[Key]注釈をつけるか、
プロパティ名をidにすると主キーとして認識されます。
(プロパティ名をidにする場合、テーブルのカラム名もidにする必要あり)

どうしても主キーを付けられない場合は、クラスに[Keyless]注釈をつけるようです。
(振る舞いがどう変わるかは以下参考。)
https://docs.microsoft.com/ja-jp/ef/core/modeling/keyless-entity-types

2. DbContext拡張クラスを作成

DbContextを継承した拡張DbContextクラスを作成します。

    public class MyContext:DbContext
    {
        public DbSet<CompanyData> CompanyStockPrice{get;set;} //このプロパティ名がテーブル名と一致してないとダメ

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            => optionsBuilder.UseMySQL(@"server=localhost;database=hoge;userid=hoge;password=hoge;sslmode=none;");
    }

DbSet型のプロパティとDB接続文字列を設定するOnConfiguringをオーバーライドしたメソッドを実装します。
(実際のシステムでは、接続文字列は外部ファイルや環境変数に外出しした方が良いでしょう。)
このDbSet型のプロパティ名のテーブルを参照するため、
アクセスしたいテーブル名とプロパティ名を一致させる必要があります。

これだけでORマッピングの実装は完了です。
昔はJavaでマッピング用の設定ファイルをガリガリ書いてたので
簡単すぎてびっくりしました。

3. DBアクセスロジックの実装

最後にDBにSQLクエリを発行して結果を受け取って処理するクラスを実装します。
サンプルとして、指定した業種・株価上位件数の会社の株価を表示するコードを書いてみます。

SQLクエリの構築、発行の実装方法としてLINQを使う方法とSQLクエリを書く方法があります。

3-1. LINQでSQLクエリ構築

    public class CompanyStockPriceSearch {

        static void Main(string[] args) {

            Console.Write("業種コード入力:");
            String target = Console.ReadLine();

            Console.Write("上位何件?:");
            int tops = int.Parse(Console.ReadLine());

            Console.WriteLine("---------------------------");

            using(var context = new MyContext())
            {
                Console.WriteLine($"業種コード「{target}」の株価Top{tops}");

                // post SQL query
                context.CompanyStockPrice
                    .Where(com => com.IndustryCode.Equals(target))
                    .OrderByDescending(com => com.StockPrice)
                    .Take(tops)
                    .ToList()
                    .ForEach(com => Console.Write($"[{com.StockCode}]{com.CompanyName} 株価={com.StockPrice}\n"));
            }

        }
    }

上記のようにLINQでSQLクエリの構築と発行ができます。
なんと・・・これは快適・・・?
SQL書かなくてもコードアシストでサクサク問い合わせ内容を掛けるのは良いですね!

実行結果は以下の通りです。

> dotnet run      
業種コード入力:10
上位何件?:5
---------------------------
業種コード「10」の株価Top5
[7974]任天堂 株価=58590
[9435]光通信 株価=25510
[4684]オービック 株価=18830
[9605]東映 株価=15760
[4661]オリエンタルランド 株価=14285

3-2. 生SQLクエリを書く

    public class CompanyStockPriceSearch {

        static void Main(string[] args) {

            // ~~~~[中略]~~~~

            using(var context = new MyContext())
            {
                Console.WriteLine($"業種コード「{target}」の株価Top{tops}");

                // post SQL query
                context.CompanyStockPrice
                    .FromSqlRaw("SELECT * FROM CompanyStockPrice"
                             + " WHERE IndustryCode = {0}"
                             + " ORDER BY StockPrice DESC"
                             + " LIMIT {1}"
                             , target, tops)
                    .ToList()
                    .ForEach(com => Console.Write($"[{com.StockCode}]{com.CompanyName} 株価={com.StockPrice}\n"));
            }
        }
    }

SQLを直接書く場合は上記のようにDbSet#FromSqlRawメソッドで指定します。
バインドパラメータを使う場合は{0},{1}のように指定して、
SQL文に続く引数に値をバインドパラメータ分だけ指定します。

またSELECT句にカラムを指定する場合は、
必ずEntityクラスのすべてのプロパティのデータを返すように指定する必要があります。

結果は下記のとおり、先ほどと同じです。

> dotnet run      
業種コード入力:10
上位何件?:5
---------------------------
業種コード「10」の株価Top5
[7974]任天堂 株価=58590
[9435]光通信 株価=25510
[4684]オービック 株価=18830
[9605]東映 株価=15760
[4661]オリエンタルランド 株価=14285

4. 補足

LINQで実装した場合、どんなSQLクエリが投げられてるんだろう??
まさかSELECT * FROM CompanyStockPrice;で全件取ってきてないよな・・・?
と思ったのでMySQLのSQLログを有効にして発行されたSQLクエリをDBサーバ側で取ってみました。

SELECT
    `c`.`StockCode`,
    `c`.`CompanyName`,
    `c`.`IndustryCode`,
    `c`.`StockPrice`
FROM
    `CompanyStockPrice` AS `c`
WHERE
    `c`.`IndustryCode` = '10'
ORDER BY
    `c`.`StockPrice` DESC
LIMIT 5

おぉーLINQの処理内容がそのままSQLクエリに反映された形になってますね。
リテラルに指定の値がそのまま記載されてますが
バインドパラメータが埋め込まれたSQLクエリ発行したときでも上記のようにログに出るので
LINQ時もバインドパラメータ化されてるでしょう・・・きっと。

まとめ

ORマッピングが設定ファイル無しでコードだけで済むのは本当に良いですね!
LINQでSQLクエリを構築する方法もやってみると実装が快適です。

ただ、実際のシステムのSQLクエリはもっと複雑なものもあるので
LINQで書く場合と生SQLを書く場合とで使い分けが必要そうです。
また、どちらでやる場合も↑には記載していない注意点がいくつかあるので
公式ドキュメントに目を通しておいた方が良いと思います。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでランダムMAP生成の方法【2Dゲーム】

UnityLearnで
用意されているローグライクゲーム。

2D Roguelike

これの
・MAPランダム生成
・オブジェクトのランダム生成
部分を解説して行きます。

この部分はローグライクの肝ですし、
何より他のゲームでも使い回せるテクニックになっています!

環境は
MacOS Catalina:ver 10.15.4
Unity:ver2019.4.3f1
です。

MAP&オブジェクトのランダム化

ランダム化のコード

BoardManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//Map生成のscriptを記述する
public class BoardManager : MonoBehaviour
{
    //Map上にランダム生成するアイテム最小値、最大値を決めるclass
    public class Count
    {
        public int minmum;
        public int maximum;

        public Count(int min,int max)
        {
            minmum = min;
            maximum = max;
        }
    }

    //Mapの縦横
    public int columns = 8;
    public int rows = 8;

    //生成するアイテムの個数
    public Count Wallcount = new Count(3, 9);
    public Count foodcount = new Count(1, 5);

    //MAPの材料
    public GameObject exit;
    public GameObject floor;
    public GameObject Wall;
    public GameObject OuterWall;
    public GameObject enemy;
    public GameObject food;

    //Object整理用(ランダム配置されたobjectの親に設定)
    private Transform boardHolder;

    //6×6のマスでobjectがない場所を管理する
    private List<Vector3> gridPositons = new List<Vector3>();

    void Start()
    {
        //Mapの生成
        BoardSetup();

        //gridPositionsのクリアと再取得
        InitialiseList();

        //ランダムに壁を生成
        LayoutObjectAtRandom(Wall, Wallcount.minmum, Wallcount.maximum);

        //食べ物生成
        LayoutObjectAtRandom(food, foodcount.minmum, foodcount.maximum);

        //出口の設置
        Instantiate(exit, new Vector3(columns - 1, rows - 1, 0), Quaternion.identity);
    }

    //フィールド生成
    void BoardSetup()
    {
        //Boardをインスタンス化してboardHolderに設定
        boardHolder = new GameObject("Board").transform;

        for (int x = -1; x < columns + 1; x++)
        {
            for (int y = -1; y < rows + 1; y++)
            {
                //床を設置してインスタンス化の準備
                GameObject toInsutantiate = floor;

                //8×8マス外は外壁を設置してインスタンス化の準備
                if (x == -1 || x == columns || y == -1 || y == rows)
                {
                    toInsutantiate = OuterWall;
                }

                //toInsutantiateに設定されたものをインスタンス化
                GameObject instance =
                    Instantiate(toInsutantiate, new Vector3(x, y, 0), Quaternion.identity) as GameObject;

                //インスタンス化された床or外壁の親要素をboardHolderに設定
                instance.transform.SetParent(boardHolder);
            }
        }
    }
    //gridPositionsをクリアする
    void InitialiseList()
    {
        //リストをクリアする
        gridPositons.Clear();

        //6×6のますをリストに取得する
        for(int x = 1; x < columns -1; x++)
        {
            for(int y = 1;y < rows -1; y++)
            {
                //gridPositionsにx,yの値をいれる
                gridPositons.Add(new Vector3(x, y, 0));
            }
        }

    }

    //gridPositionsからランダムな位置を取得する
    Vector3 RandomPosition()
    {
        //randomIndexを宣言して、gridPositionsの数から数値をランダムで入れる
        int randomIndex = Random.Range(0, gridPositons.Count);

        //randomPositionを宣言して、gridPositionsのrandomIndexに設定する
        Vector3 randomPosition = gridPositons[randomIndex];

        //使用したgridPositionsの要素を削除
        gridPositons.RemoveAt(randomIndex);

        return randomPosition;
    }

    //Mapにランダムで引数のものを配置する(敵、壁、アイテム)
    void LayoutObjectAtRandom(GameObject tile,int minimum,int maximum)
    {
        //生成するアイテムの個数を最小最大値からランダムに決め、objectCountに設定する
        int objectCount = Random.Range(minimum, maximum);

        //設置するオブジェクトの数分ループで回す
        for(int i = 0; i < objectCount; i++)
        {
            //現在オブジェクトが置かれていない、ランダムな位置を取得
            Vector3 randomPosition = RandomPosition();

            //生成
            Instantiate(tile, randomPosition, Quaternion.identity);
        }
    }
}

コード解説

BoardSetup

ループを使いフィールドを生成しています

下記の図を見てください

スクリーンショット 2020-09-01 20.30.28 2.png

フィールド全体は
10×10
で生成されており、
一番外側は
破壊不可なOuterWall(外壁)を設置する場所です。

8×8はPlayerが移動可能な範囲で、
なおかつfloor(床)を設置する場所。

Playerが現在立っている位置を
(0,0)としているため、
外壁が配置されるのは
x,yのどちらかまたは両方に-1or8の位置がある時になります。

なのでループの開始を-1から始め10進んだ数値の8までループが回るようにし、
x,yに-1or8が含まれていたらOuterWall(外壁)、
それ以外はfloor(床)を配置するようにしています。

InitialiseList

上記画像の6×6の場所を
リスト(gridPositons)に数値としてに保持する

このgridPositonsに保持されている数値は、
objectが無い状態です。

アイテムを配置する際にこのリストを使って
Objectがない位置を把握して、配置して行きます。

RandomPosition

randomIndexに
gridPositonsに保持されている位置の要素数から
ランダムに数値を選び設定する。

randomPositionにgridPositonsの
randomIndexに保持されている場所を設定する。

一度randomPositionに設定された数値は削除して、
アイテムが二重で置かれることがないようにしている。
スクリーンショット 2020-09-26 11.12.10.png

LayoutObjectAtRandom

アイテムごとに決められた、
最小最大値からランダムでアイテムを設置するべき数値を決める。

決められた数値objectCount分ループで回して、
randomPositionに設置していく。

最後に

MAPのランダム生成について解説して行きました。
今後もゲーム開発に役立つテクニックを
投稿していこうと思っています!

また自分のブログでは
初心者でもゲーム開発ができるように1から丁寧に解説しています。
ローグライクのゲームの作り方を
1から知りたい方はぜひブログを覗いてみてください!
ブログを覗いてみる

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む