20201212のC#に関する記事は13件です。

Xamarin.FormsでAndroidのデバイスの設定の影響でアプリのフォントサイズが変わらないようにする方法

はじめに

Androidは、下図のような画面で、デバイスのフォントサイズを変更できます。
これは、アプリのフォントサイズに影響するため、普通にXamarin.Formsでアプリを開発すると、フォントサイズがこの設定の影響で変わってしまいます。それを変わらないようにする方法の紹介です。

実装方法

Androidプロジェクトにある MainActivity.cs に以下のコードブロックを追加することで、デバイスのフォントサイズに影響しなくなります。

MainActivity.cs

public override Android.Content.Res.Resources Resources
{
    get
    {
        Android.Content.Res.Resources res = base.Resources;
        Configuration config = new Configuration();
        config.SetToDefaults();
        res?.UpdateConfiguration(config, res.DisplayMetrics);
        return res;
    }
}

ただし、上記で利用している Resources.UpdateConfigurationメソッドは、Android API 25(Android7.1)以上では、非推奨となっています。
詳しくは、以下に書いてあります。
Resources | Android Developers

従って、Android API 25(Android7.1)以降と、それより前で分岐して、別々の方法で実装する必要があります(自分で確認した限り、新しい方式の実装は、Android API 24(Android7.0)では、正しく動作しませんでした)。

public override Android.Content.Res.Resources Resources
{
    get
    {
        if (DeviceInfo.Version.Major < 7 ||
            (DeviceInfo.Version.Major == 7 && DeviceInfo.Version.Minor == 0))
        {
            // Android API 24(Android7.0)以前は古い方式で実装
            Android.Content.Res.Resources res = base.Resources;
            Configuration config = new Configuration();
            config.SetToDefaults();
            res?.UpdateConfiguration(config, res.DisplayMetrics);
            return res;
        }
        else
        {
            // Android API 25(Android7.1)以降は新しい方式で実装
            var config = new Configuration();
            config.SetToDefaults();
            return CreateConfigurationContext(config)?.Resources;
        }
    }
}

古い方式と新しい方式のそれぞれの実装方法は、以下の stack overflow の記事を参考にしています。
How to Prevent Device font size effect of Xamarin android app?

まとめ

Xamarin.FormsはC#でWindowsとAndroidとiOSのアプリがまとめて開発できて便利です。

ちなみに私は、普段はエンジニアリングマネージャーとして、チームの皆で楽しく開発する施策を色々実施しています。詳しくは以下を参照ください。
1年以上かけて生産性倍増+成長し続けるチームになった施策を全部公開

Twitterでも開発に役立つ情報を発信しています → @kojimadev

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

NMeCabを使ったパーフェクトVSTO湯婆婆

はじめに

 映画「千と千尋の神隠し」の登場人物である湯婆婆と、主人公の千尋とのやりとりを実装したものが、@NemesisさんのJavaで湯婆婆を実装してみるの記事です。これに触発されて、WORD VBAで湯婆婆や、Autohotkeyでトースト湯婆婆の記事を投稿いたしました。
 そして、「NameDividerを触媒にパーフェクト湯婆婆を創造する」の記事を見て思いました。まだ自分のコーディングしたものはパーフェクトではなかった、と・・・。なお、パーフェクト湯婆婆の動作仕様は以下です。

千尋は契約書にフルネームを書き、湯婆婆は下の名前だけを口にしたうえで、下の名前から1文字を残し、名前の他の部分を奪った、のです。

 しかし、パーフェクト湯婆婆の作成には、日本語の形態素解析エンジンを組み込むことが必要であり、AutohotkeyやVBAでは対応できません。
 そこで作成したのが、NMeCabを使ったパーフェクトVSTO湯婆婆です。なお、この記事は、VSTO湯婆婆の紹介と共に、単一バイナリのNMeCabのdllの紹介も兼ねております。
 パーフェクトVSTO湯婆婆は、GitHubにソースコードとインストーラ込みで公開しています。
https://github.com/k-ayaki/yubaba

MeCabとは

 MeCabとは、MeCabは 京都大学情報学研究科−日本電信電話株式会社コミュニケーション科学基礎研究所 共同研究ユニットプロジェクトを通じて開発されたオープンソース 形態素解析エンジンです。開発者は工藤卓氏であり、C++で記述されています。

NMeCabとは、

 NMeCabとは、日本語形態素解析エンジンであるMeCabをC#に移植したものです。MeCabの様々な移植のうち .Net Framework環境ではもっとも安定して動作します。

 余談ですが、筆者はOSDNにアップロードされているNMeCabをベースに作業していました。Githubに新しい版がアップロードされてることに今更ながら気づいた次第です。

NMeCab単一バイナリ版とは

 NMeCab単一バイナリ版とは、筆者がNMeCabをdll化して、かつ、辞書をリソースとして含んだものです。これにより、インストール時や動作時における辞書パスの設定に悩まされずにすみます。今回は、このNMeCabの単一バイナリ版を用いて、パーフェクト湯婆婆を作成します。なお、単一バイナリのNMeCabのDLLは、以下に公開しています。
 https://github.com/k-ayaki/NMeCabSb

VSTOとは

 VSTO とは、Visual Studio Tools for Officeの略称であります。簡単にいうと、VBAと同様なWORD(Office)アドインを作成するためのフレームワークです。Visual Studioにソリューションのスケルトンが実装されています。VSTOを使うと、Microsoft Word上のテキストを直接に操作するアドインをC#で作成可能です。VBAの独特のクセが気になる方にはお勧めです。

 詳しくは、Office ソリューションの開発の概要 (VSTO)や、VSTOって何よってお話をご覧ください。なお、日本語の書籍としては、10年以上もまえに出版されたVSTOとSharePoint Server 2007による開発技術~Visual Studio 2008で構築するOBAソリューションがあるだけのようです。VSTOについては、VBAと比べて技術情報が少なく、やや苦労します。

VSTOのプロジェクトの設定

Visual Studio 2019にて「新しいプロジェクトの作成」を選択して、Word VSTO アドイン(C#)を選択してください。
yubaba00.PNG

そして、プロジェクトの作成先フォルダを選択します。これにより、ブロジェクトの雛形が作成されます。
yubaba01.PNG

次に、yubabaソリューション上で右クリックし、「新しい項目」を選択します。ここで、リボン(ビジュアルなデザイナー)を選択してください。
yubaba02.PNG

これにより、yubabaRibbon.csが追加されます。
yubaba03.PNG

ツールボックスにより、yubabaRibbon.cs にボタンを追加します。
yubaba04.PNG

そして、湯婆婆ボタンにアイコンを追加します。

yubaba64.png

 湯婆婆ボタンのColtrolSizeは、RibbonControlSizeLargeを選択してください。湯婆婆の顔をはっきりと見せるためです。そして、グループとタブのラベルを適宜設定してください。ここではグループのラベルに "Qiita"を設定し、タブのラベルに "AppLint" を設定しています。
yubaba08.PNG

そして、契約書フォームを追加します。フォームについては、普通のC#アプリケーションと同様です。
yubaba09.PNG

NMeCabの組み込み

yubabaプロジェクトの「参照」を選択して右クリックし、コンテキストメニューの「参照の追加」を選択してください。そして、LibNMeCab.dll (単一バイナリのNMeCabのDLL)を選択してください。

yubaba10.png

動作説明

 WordのAppLintタブに、湯婆婆アイコンが表示されています。
yubaba_word11.png

 湯婆婆アイコンをクリックすると、「契約書だよ。そこ(乙)に名前を書きな。」をWord画面に追加して、以下の契約書フォームを表示します。乙に人名(ここでは荻原千尋)を入力してOKボタンをクリックします。
yubaba_word12.png

湯婆婆は下の名前だけを口にして、この下の名前から1文字を残して名前の他の部分を奪います。

 yubaba_word13.png

ソースコード説明

 ワード(アプリケーション)は、Globals.ThisAddIn.Applicationオブジェクトです。このうち、Selection(選択範囲)オブジェクトを操作することで、ワード画面に段落や文字を入力できます。なお、Selectionオブジェクトとは、現在の選択範囲に係るオブジェクトのことです。
 app.Selection.TypeParagraph(); は、ワードのパラグラフの入力です。
 app.Selection.TypeText(); は、ワードへのテキスト入力です。ここでは、「契約書だよ。そこ(乙)に名前を書きな。」を画面に入力したのち、契約書フォームを呼び出して名前を入力させています。
 cf.yourName は、ユーザが入力した名前です。
 入力した名前をYNameクラスに設定し、人名の下の名前 (yn.firstName) が取得できたならば、1文字だけ残した新たな名前 (yn.newName) を表示しています。
 なお、形態素解析(NMeCab)によって、下の名前が検知できなかった場合には、「フン、" + yn.strangeName + "というのかい、おかしな名だねえ」と表示させています。このとき、yn.strangeNameには、入力した全ての文字列が格納されます。

yubabaRibbon.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Office.Tools.Ribbon;
using System.Windows.Forms;
using yubaba.Name;
using Microsoft.Office.Interop.Word;

namespace yubaba
{
    public partial class yubabaRibbon
    {
        private void yubabaRibbon_Load(object sender, RibbonUIEventArgs e)
        {

        }

        private void button1_Click(object sender, RibbonControlEventArgs e)
        {
            Microsoft.Office.Interop.Word.Application app = Globals.ThisAddIn.Application;
            app.Selection.TypeParagraph();
            app.Selection.TypeText("湯婆婆「契約書だよ。そこ(乙)に名前を書きな。」");
            app.Selection.TypeParagraph();

            contractForm cf = new contractForm();
            cf.ShowDialog();
            YName yn = new YName(cf.yourName);

            if(yn.fStrange == false)
            {
                app.Selection.TypeText("湯婆婆「フン、" + yn.firstName + "というのかい、贅沢な名だねえ」");
                app.Selection.TypeParagraph();
                app.Selection.TypeText("湯婆婆「今日からお前の名前は" + yn.newName + "だ、いいかい、" + yn.newName + "だよ。分かったら返事をするんだ、" + yn.newName + "」");
            }
            else
            {
                app.Selection.TypeText("湯婆婆「フン、" + yn.strangeName + "というのかい、おかしな名だねえ」");
                app.Selection.TypeParagraph();
                app.Selection.TypeText("湯婆婆「今日からお前の名前は" + yn.newName + "だ、いいかい、" + yn.newName + "だよ。分かったら返事をするんだ、" + yn.newName + "」");
            }
        }
    }
}

契約書フォームは、OKボタンをクリックしたときに当該フォームを閉じることと、textBox1に入力されたテキストをyourNameとして返すことが記載されています。

contractForm.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace yubaba
{
    public partial class contractForm : Form
    {
        public contractForm()
        {
            InitializeComponent();
        }
        public string yourName
        {
            get
            {
                return textBox1.Text;
            }
        }

        private void button1_Click(object sender, EventArgs e)
        {
            this.DialogResult = DialogResult.OK;
            this.Close();
        }
    }
}

YNameクラスは、NMeCabを呼び出して入力文字列 inString を形態素解析し、名詞・固有名詞・人名・名のチャンクがあれば下の名前として、その中から1文字を取り出して新しい名としています。名詞・固有名詞・人名・名のチャンクがなければ、inString を strangeName に代入して、その中から1文字を取り出して新しい名としています。

YName.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NMeCab;

namespace yubaba.Name
{
    class YName
    {
        public string firstName { get; }
        public string strangeName { get; }
        public string newName { get; }
        public bool fStrange { get; }
        public YName(string inString)
        {
            this.firstName = "";
            this.strangeName = "";
            this.newName = "";
            this.fStrange = true;
            var mecab = MeCabTagger.Create();
            MeCabNode node = mecab.ParseToNode(inString);
            node = node.Next;
            while (node != null)
            {
                if (node.Feature != "BOS/EOS,*,*,*,*,*,*,*,*")
                {
                    Chunk chunk = new Chunk(node);
                    if (chunk.isPartOfSpeech("名詞","固有名詞","人名","名"))
                    {
                        this.firstName = node.Surface;
                        fStrange = false;
                    }
                }
                node = node.Next;
            }
            mecab.Dispose();

            var random = new Random();
            if (this.firstName.Length > 0)
            {
                newName = firstName.Substring((int)(random.Next(0, firstName.Length)), 1);
            } else
            {
                strangeName = inString;
                newName = strangeName.Substring((int)(random.Next(0, strangeName.Length)), 1);
            }
        }
    }
}

Chunkクラスは、形態素解析後の各ノードを記憶するものです。
なお、NMeCabが、「荻原千尋」を形態素解析すると以下となります。

Surface="荻原", Feature="名詞,固有名詞,人名,姓,,,荻原,オギワラ,オギワラ"
Surface="千尋", Feature="名詞,固有名詞,人名,名,,,千尋,チヒロ,チヒロ"

Surface を表層形のメンバー変数に、Feature をカンマで区切って各メンバー変数に設定しています。これにより、C#でアクセスしやすくなります。

Chunk.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NMeCab;

namespace yubaba.Name
{
    class Chunk
    {
        public string 表層形 { get; set; }
        public string 品詞 { get; set; }
        public string 品詞細分類1 { get; set; }
        public string 品詞細分類2 { get; set; }
        public string 品詞細分類3 { get; set; }
        public string 活用形 { get; set; }
        public string 活用型 { get; set; }
        public string 原形 { get; set; }
        public string 読み { get; set; }
        public string 発音 { get; set; }
        public string padding { get; set; }
        public string feature { get; set; }
        public MeCabNodeStat stat { get; set; }
        public Chunk(MeCabNode node)
        {
            表層形 = node.Surface;
            stat = node.Stat;

            feature = node.Feature;
            string[] features = node.Feature.Split(',');
            品詞 = "未定義";
            品詞細分類1 = "";
            品詞細分類2 = "";
            品詞細分類3 = "";
            活用形 = "";
            活用型 = "";
            原形 = "";
            読み = "";
            発音 = "";
            if (1 <= features.Length) 品詞 = features[0];
            if (2 <= features.Length) 品詞細分類1 = features[1];
            if (3 <= features.Length) 品詞細分類2 = features[2];
            if (4 <= features.Length) 品詞細分類3 = features[3];
            if (5 <= features.Length) 活用形 = features[4];
            if (6 <= features.Length) 活用型 = features[5];
            if (7 <= features.Length) 原形 = features[6];
            if (8 <= features.Length) 読み = features[7];
            if (9 <= features.Length) 発音 = features[8];
        }
        // 品詞とチャンクとの照合
        public bool isPartOfSpeech(string szPartOfSpeech, string szPartOfSpeechSC1 = null, string szPartOfSpeechSC2 = null, string szPartOfSpeechSC3 = null)
        {
            if (品詞 != szPartOfSpeech)
            {
                return false;
            }
            if (szPartOfSpeechSC1 == null)
            {
                return true;
            }
            if (品詞細分類1 != szPartOfSpeechSC1)
            {
                return false;
            }
            if (szPartOfSpeechSC2 == null)
            {
                return true;
            }
            if (品詞細分類2 != szPartOfSpeechSC2)
            {
                return false;
            }
            if (szPartOfSpeechSC3 == null)
            {
                return true;
            }
            if (品詞細分類3 != szPartOfSpeechSC3)
            {
                return false;
            }
            return true;
        }
    }
}

インストーラ

 VSTOで最もありがたいのは、インストーラ作成がVisual Studio 2019上で簡単にできることだとおもいます。

 最初にビルドメニューから、「yubabaの発行」をクリックすると、以下の公開ウィザードが表示されますので、ディスクパスを入力します。なお、共有サーバやFTPサーバに発行することも可能なようですが、試していません。
yubabaSetup01.PNG

そして、規定のインストールパスをCD-ROMまたはDVD-ROMとします。

yubabaSetup02.PNG

これによりSETUP.EXE が作成されました。

yubabaSetup03.PNG

SETUP.EXEの実行により、湯婆婆がインストールされます。

yubabaSetup04.PNG

余談

 現総理の「菅義偉」を入力すると、下の名前は「義」と認識されます。前総理の「安倍晋三」を入力すると、下の名前は「晋」と認識されます。NMeCabの固有名詞辞書にない名前はやや難しいようです。
yubaba_word14.png

yubaba_word15.png

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

WPFでタスクトレイ常駐アプリを作る

WPFでタスクトレイ(通知領域)常駐アプリを作ろうとしたら想像以上に手間がかかったので備忘録として初投稿です。

動作環境

  • Visual Studio 2019
  • .NET Core3.1(.NET5.0でも可)

事前準備

新しいプロジェクトの作成からWPF App(.NET)を作成しておく。

アイコンの準備

まずタスクトレイにアイコンを表示させるためicoファイルを作成しておきます。
Inkscape等のドローソフトを使うのが一番楽だと思います。
ちなみにウインドウズ標準形式アイコン作成等のサイトを使用すれば簡単にマルチアイコンが作成できます。
icoファイルを作成し終えたらソリューションエクスプローラから既存の項目として追加します。
既存の項目.png
その後プロパティのビルドアクションをリソースに変更してください。
icoファイルのプロパティ.png

Formクラスの準備

多分一番躓きやすいポイントだと思います。
他のサイトだと参照を追加してくださいとか色々書かれてると思いますが今はプロジェクトファイルの<PropertyGroup>内に

.csproj
<UseWindowsForms>true</UseWindowsForms>

と追記することでFormクラスを扱うことができます。

WFPでタスクトレイを作る

長い準備も終わったため、いよいよタスクトレイのプログラミングです。App.xaml.csのOnStartupメソッドに以下のように書いてください。

App.xaml.cs
public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            var icon = GetResourceStream(new Uri("icon.ico", UriKind.Relative)).Stream;
            var notifyIcon = new System.Windows.Forms.NotifyIcon
            {
                Visible = true,
                Icon = new System.Drawing.Icon(icon),
                Text = "タスクトレイ常駐アプリのテストです"
            };
        }
    }

変数iconのicon.icoには自分が用意したicoファイルの名前を使用してください。
Textはタスクトレイのアイコンにマウスオーバーしたときに出るツールチップのテキストです。
タスクトレイのツールチップ.png

コンテキストメニューの追加

App.xaml.cs
public partial class App : Application
    {
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);

            var icon = GetResourceStream(new Uri("icon.ico", UriKind.Relative)).Stream;
            var menu = new System.Windows.Forms.ContextMenuStrip();
            menu.Items.Add("終了", null, Exit_Click);
            var notifyIcon = new System.Windows.Forms.NotifyIcon
            {
                Visible = true,
                Icon = new System.Drawing.Icon(icon),
                Text = "タスクトレイ常駐アプリのテストです",
                ContextMenuStrip = menu
            };
            notifyIcon.MouseClick += new System.Windows.Forms.MouseEventHandler(NotifyIcon_Click);
        }

        private void NotifyIcon_Click(object sender, System.Windows.Forms.MouseEventArgs e)
        {
            if (e.Button == System.Windows.Forms.MouseButtons.Left)
            {
                var wnd = new MainWindow();
                wnd.Show();
            }
        }

        private void Exit_Click(object sender, EventArgs e)
        {
            Shutdown();
        }
    }

Items.Addでコンテキストメニューを追加します。
タスクトレイのコンテキストメニュー.png
またMouseClickイベントで左クリックの動作を決めることもできます。

実行時にウィンドウが表示されないようにする

これでタスクトレイに表示をすることができましたが、常駐ソフトなのに実行時にいちいちウィンドウが表示されるのは鬱陶しいため実行時にはウィンドウが表示されないようにします。
App.xamlのStartupUriを削除すればウィンドウは表示されずに動作します。
スタートアップの削除.png

まとめ

WPFでタスクトレイを扱うには<UseWindowsForms>true</UseWindowsForms>が必要。
OnStartupメソッドに処理を書き込む。
ついでにApp.xamlのStartupUriを削除する。

参考記事

Windowsのタスクトレイ常駐型アプリを作る
Using System.Windows.Forms classes in a .net core 3.0 preview9 project
[WPF] リソースの画像から BITMAP オブジェクトを生成する
C#のタスクトレイ常駐アプリの作り方のご紹介!
C# Right mouse click on button does not raise mouseclick event

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

Blazor から JavaScript ライブラリを利用する

Blazor における C# と JavaScript との相互運用 (JavaScript Interop) 、また JavaScript ライブラリの利用について解説します。

環境:

  • .NET 5
  • Blazor WebAssembly

JavaScript Interop の挙動を確認する

Blazor で JavaScript Interop を利用する際は、以下のステップが必要になります。

  1. /wwwroot 配下に JavaScript モジュールを配置する
  2. C# から JavaScript モジュールをインポートする
  3. モジュールが公開している関数を C# から呼び出す

まず、呼び出される JavaScript モジュールを以下の通り定義します。

wwwroot/js/interop-sample.js
export function outputLog(obj) {
   console.log(typeof obj, obj);
}

C# のソースコードから上記モジュールをインポートし、関数を呼び出します。まず、次の名前空間への参照を追加します。

InteropSample.razor
@inject IJSRuntime JSRuntime

これにより、以下の手順で JavaScript モジュールを参照できます。

InteropSample.cs
var module = await JSRuntime.InvokeAsync<IJSObjectReference>(
   "import", "./js/interop-sample.js"
);

await module.InvokeVoidAsync("outputLog", "my first interop.");

InvokeVoidAsync() では、実行する関数名を第一引数で、関数へ渡す引数項目をそれ以降の引数で指定します。

これにより引数 obj へ渡した値の型 string とその内容 "my first interop" が、ブラウザーのコンソール上へ出力されます。

string my first interop.

引数として渡せる値は JavaScript 向けに変換が行われます。変換結果の例としては以下の通りです。

InteropSample.cs
await module.InvokeVoidAsync("outputLog", null);
await module.InvokeVoidAsync("outputLog", true);
await module.InvokeVoidAsync("outputLog", false);
// null は undefined へ、bool は boolean へそれぞれ変換される。
//   undefined undefined
//   boolean true
//   boolean false

await module.InvokeVoidAsync("outputLog", 123);
await module.InvokeVoidAsync("outputLog", "456");
await module.InvokeVoidAsync("outputLog", "foo");
// 数値を渡した場合は number へ、文字列を渡した場合は string へ変換される。
//   number 123
//   string 456
//   string foo

// enum MyEnum { Value1 = 7, Value2 = 5, Value3 = 6 }
await module.InvokeVoidAsync("outputLog", MyEnum.Value1);
await module.InvokeVoidAsync("outputLog", MyEnum.Value2);
await module.InvokeVoidAsync("outputLog", MyEnum.Value3);
// 列挙型のメンバーは、対応する整数値へ変換される。
//   number 7
//   number 5
//   number 6

var sampleList = new List<string>() { "aaa", "bbb", "ccc" };
await module.InvokeVoidAsync("outputLog", sampleList);
await module.InvokeVoidAsync("outputLog", sampleList as IEnumerable<string>);
// IEnumerable インターフェイスを備えた型であれば配列へ変換される。
//   object (3) ["aaa", "bbb", "ccc"]
//   object (3) ["aaa", "bbb", "ccc"]

// class SampleClass
// {
//    private string _X = "private field";
//    public string X = "public field";
//    private string _Y { get; set; } = "private property";
//    public string Y { get; set; } = "public property";
// }
var sampleInstance = new SampleClass();
await module.InvokeVoidAsync("outputLog", sampleInstance);
// クラスインスタンスは public プロパティのみを含んだ object へ変換される。
//   object {y: "public property"}

await module.InvokeVoidAsync("outputLog", new Dictionary<int, string>() {
    { 1, "value1" },
    { 2, "value2" },
    { 3, "value3" },
});
// Dictionary は object へ変換される。
//   object {1: "value1", 2: "value2", 3: "value3"}

await module.InvokeVoidAsync("outputLog", new
{
   Item1 = "foo",
   Item2 = "hoo",
   Item3 = 123
});
// 匿名型も object へ変換される。
//   object {item1: "foo", item2: "hoo", item3: 123}

var sampleTuple = (909, 703);
await module.InvokeVoidAsync("outputLog", sampleTuple);
await module.InvokeVoidAsync("outputLog", sampleTuple.ToString());
// Tuple は変換を行えない。
//   object {}
//   string (909, 703)

var sampleValueTuple = (x: 123, y: "sample");
await module.InvokeVoidAsync("outputLog", sampleValueTuple);
await module.InvokeVoidAsync("outputLog", sampleValueTuple.ToString());
// ValueTuple も同様。
//   object {}
//   string (123, sample)

この変換処理は以下の注意点があります。

  • クラス インスタンスの持つプロパティ名は、先頭を小文字に変換される
  • TupleValueTuple といった型を持つ値は変換を行えない

Tuple が利用できないのは意外な気もしますが、JavaScript 側で対応するデータ構造がないことが理由なのかもしれません。

JavaScript ライブラリを利用した処理をモジュールとして定義する

JavaScript Interop による関数の呼び出しと、関数へ渡した値が変換される際の挙動について確認しました。続いて、JavaScript ライブラリを用いて DOM を操作するパターンについて確認します。

例として、Chart.js を利用して画面上へグラフを出力するまでの流れを確認します。

Blazor のテンプレート プロジェクトでは、連日の気温を一覧表示する FetchData.razor というサンプル コンポーネントが含まれています(気温の数値自体はダミーデータとなっています)。これをもとに、気温の変化を日付順のグラフとして出力させます。

image.png

まず、Chart.js のライブラリ本体をダウンロードします。

これを /wwwroot/js/lib へ配置し、/wwwroot/index.html にライブラリへの参照を追加します。

wwwroot/index.html
    <head>
       <meta charset="utf-8" />
       ... 中略
       <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
+      <script src="js/lib/Chart.min.js"></script>
    </head>

/wwwroot/js 配下に、Chart.js を呼び出す以下のモジュールを定義します。

chart-companion.js
export function createGraph(context, graph) {
   new Chart(context, graph)
}

Razor コンポーネント ファイル FetchData.razor にて IJSRuntime への参照と、グラフの出力先となる canvas 要素、要素への参照を格納する ElementReference 型のフィールドを追加します。

Pages/FetchData.razor
+   @inject IJSRuntime JSRuntime

    <h1>Weather forecast</h1>
    ... 中略

    @if (forecasts == null)
    {
       <p><em>Loading...</em></p>
    }
    else
    {
+      <canvas @ref="graphCanvas"></canvas>
+   
       <table class="table">
          <thead>
             <tr>
        ... 中略
    }

    @code {
       private WeatherForecast[] forecasts;
+      private ElementReference graphCanvas;

上記 Razor コンポーネントに対するコードビハインドとして、FetchData.razor.cs を作成します。

Pages/FetchData.razor.cs
namespace BlazorSamples.Client.Pages
{
   public partial class FetchData { }
}

Chart.js へ渡すパラメータと対応するデータクラスを定義します。

Pages/FetchData.razor.cs
public partial class FetchData
{
   class LineData
   {
      public string Label { get; set; }
      public IEnumerable<int> Data { get; set; }
      public double Tension { get; set; } = 0.5;
      public string BorderColor { get; set; }
   }

   class LineGraphData
   {
      public IEnumerable<string> Labels { get; set; }
      public IEnumerable<LineData> Datasets { get; set; }
   }

   class LineGraph
   {
      public string Type { get; } = "line";
      public LineGraphData Data { get; set; }
   }
}

次に、これらデータクラスを用いてパラメータを構成する処理を追加します。

Pages/FetchData.razor.cs
protected async void CreateGraph()
{
   // WeatherForecastService から得られたデータをグラフ出力用データに変換する
   var temparetures = new LineGraphData()
   {
      // 各データの日付をグラフの軸とする
      Labels = forecasts.Select(f => f.Date.ToShortDateString()),

      // 摂氏/華氏それぞれの気温情報をグラフの要素として指定する
      Datasets = new List<LineData>()
      {
         new LineData()
         {
            Label = "Temp. (C)",
            Data = forecasts.Select(f => f.TemperatureC),
            BorderColor = "coral",
         },
         new LineData()
         {
            Label = "Temp. (F)",
            Data = forecasts.Select(f => f.TemperatureF),
            BorderColor = "lightgreen",
         }
      }
   };

   // コンパニオン モジュールへデータを渡す
   var module = await JSRuntime.InvokeAsync<IJSObjectReference>(
      "import", "./js/chart-companion.js"
   );

   await module.InvokeVoidAsync(
      "createGraph",
      graphCanvas,
      new LineGraph() { Data = temparetures }
   );
}

上記関数の呼び出しを、ページの初期化処理中に追加します。

Pages/FetchData.razor
    protected override async Task OnInitializedAsync()
    {
       forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
+      CreateGraph();
    }

以上の変更により、画面上へ以下の通りグラフが出力されるようになりました。

image.png

参考:

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

C# Attributeを利用した、お手軽!CSVの入出力クラスを作ろう!

お久しぶりです.
株式会社グレンジでエンジニアマネージャー兼クライアントエンジニアのmesshiです.
(CyberAgentグループ、ゲーム事業部の子会社です)

さて、今年もAdventCalendarの時期がやってきたので、ひょっこり投稿しておこうかなと思います.

まえおき

今回取り上げる記事は、次のようなニーズがある方に役立つかと思います.

・プロダクトへ入れるソースコードは最低限にしたい (ThirdParty製を入れたくない)
・入出力部分のメンテナンスはしたくない
・デバッグ用のコードなので、多少パフォーマンス悪くても良い

私の利用用途は少し特殊で、ゲーム事業部の各子会社のチューニングを協力させてもらう事が多いのですが
チューニングのイテレーションをガンガン回すために、次のようなワークフローを作ります.
「①実機計測」→「②計測結果をCsvに書き出し」→「③Csvを送信」→「④データとして取り込み可視化」

その際、Csvに関する部分に関して、上記のようなニーズがありました.

では、前置きが長くなりましたが、早速作っていきましょう!

Attributeを定義する

AttributeにCsvの入出力に必要なメタ情報を持たせます.
ヘッダー名、ヘッダーの順番さえあれば事足りるでしょう.
それを定義したクラスを生成します.

SimapleCsvAttribute.cs
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class SimpleCsvAttribute : Attribute
{
    #region variable

    /// 列順
    private int _order;

    #endregion

    #region Property

    /// 列順
    public int Order => _order;
    /// 名前
    public string Name { get; set; }

    #endregion

    #region method

    public SimpleCsvAttribute(int order) {
        _order = order;
    }

    #endregion
}

AttributeUsageは、Attributeを付与できる対象のことです.
今回はProperty属性をしています

AllowMultipleは複数のAttributeを設定できるかどうかです
デフォルトfalseなので指定しなくてもOKです

データを定義する

プロパティでCsvに書き出すデータを定義します.

ProfileData.cs
/// <summary>
/// ProfileData
/// </summary>
public class ProfileData
{
    [SimpleCsv(1)]
    public string Name { get; set; }
    [SimpleCsv(2, Name = "処理時間")]
    public int Time { get; set; }
}

[SimpleCsv]のAttributeを付けなければ書き出しが行われません.
Nameの指定をしなかった場合は、プロパティの変数名をそのまま使用します

書き込みクラスを定義する

コンストラクタでGenericに指定されたクラスから、Attributeの情報を抜き出し、メンバ変数のリストに格納します.
あとは、そのAttributeの情報からデータを書き込むだけです.

SimpleCsvWriter.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Linq;
using System.Reflection;
using System.Text;

/// <summary>
/// SimpleCsvWriter
/// </summary>
public class SimpleCsvWriter<T> : IDisposable where T : class, new()
{
    #region define

    /// 区切り文字
    private const string DELIMITER = ",";

    /// 引用符の正規表現
    private const string QUOTE_REGEX = "[\"\\r\\n,]";

    /// 属性プロパティ情報
    public class AttributePropertyInfo
    {
        public PropertyInfo Property;
        public SimpleCsvAttribute CsvAttribute;
    }

    /// 値取得のRegex
    private static readonly Regex VALUE_REGEX = new Regex(QUOTE_REGEX);

    /// ファイルのエンコードタイプ
    private static readonly Encoding FILE_ENCODING = Encoding.UTF8;

    #endregion

    #region variable

    /// StreamWriter
    private StreamWriter _writer;

    /// 属性の情報リスト
    private List<AttributePropertyInfo> _attirbuteInfos;

    /// データリスト
    private T[] _records;

    #endregion

    #region method

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public SimpleCsvWriter(string directory, string fileName, T[] records) {
        if (!Directory.Exists(directory)) {
            Directory.CreateDirectory(directory);
        }

        _records = records;
        var filePath = directory + Path.DirectorySeparatorChar + fileName + ".csv";
        _writer = new StreamWriter(filePath, false, FILE_ENCODING);

        var targetType = GetType().GetGenericArguments()[0];
        _attirbuteInfos = targetType
            .GetProperties()
            .Select(x => new AttributePropertyInfo() {
                Property = x,
                CsvAttribute = x.GetCustomAttributes(typeof(SimpleCsvAttribute), false).FirstOrDefault() as SimpleCsvAttribute
            })
            .Where(x => x.CsvAttribute != null)
            .OrderBy(x => x.CsvAttribute.Order)
            .ToList();
    }

    /// <summary>
    /// 書き込む
    /// </summary>
    public void Write() {
        WriteHeader();
        for (int i = 0; i < _records.Length; i++) {
            WriteLine(_records[i]);
        }
    }

    /// <summary>
    /// ヘッダーを書き込む
    /// </summary>
    private void WriteHeader() {
        var headers = _attirbuteInfos.Select(x => x.CsvAttribute.Name ?? x.Property.Name)
            .Select(x => Quote(x))
            .ToArray();
        _writer.WriteLine(string.Join(DELIMITER, headers));
    }

    /// <summary>
    /// 1行書き込む
    /// </summary>
    private void WriteLine(T record) {
        var values = _attirbuteInfos.Select(x => x.Property.GetValue(record))
            .Select(x => Quote(x))
            .ToArray();

        _writer.WriteLine(string.Join(DELIMITER, values));
    }

    /// <summary>
    /// 引用符を変換する (コンマやダブルクォーテーションなど)
    /// </summary>
    private string Quote(object value) {
        string target = value != null ? value.ToString() : "";
        if (VALUE_REGEX.Match(target).Success)
        {
            return "\"" + target.Replace("\"", "\"\"") + "\"";
        }
        else
        {
            return target;
        }
    }

    /// <summary>
    /// 破棄処理
    /// </summary>
    public void Dispose() {
        _writer.Dispose();
    }

    #endregion
}

読み込みクラスを定義する

書き込みとほぼ同じ要領で行います

SimpleCsvReader.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using System.Linq;
using System.Reflection;
using System.Text;

/// <summary>
/// SimpleCsvReader
/// </summary>
public class SimpleCsvReader<T> : IDisposable where T : class, new()
{
    #region define

    /// 引用符
    private const string QUOTE = "\"";
    /// 改行正規表現
    private const string NEW_LINE_PATTERN = "(?:\x0D\x0A|[\x0D\x0A])?$";
    /// 区切り正規表現
    private const string DELIMITER_PATTERN = "(\"[^\"]*(?:\"\"[^\"]*)*\"|[^,]*),";

    /// 属性プロパティ情報
    public class AttributePropertyInfo
    {
        public PropertyInfo Property;
        public SimpleCsvAttribute CsvAttribute;
    }

    /// 引用符の正規表現
    private static readonly Regex QUOTE_REGEX = new Regex(QUOTE);
    /// 改行コードの正規表現
    private static readonly Regex NEW_LINE_REGEX = new Regex(NEW_LINE_PATTERN, RegexOptions.Singleline);
    /// 区切り文字の正規表現
    private static readonly Regex DELIMITER_REGEX = new Regex(DELIMITER_PATTERN);

    /// ファイルのエンコードタイプ
    private static readonly Encoding FILE_ENCODING = Encoding.UTF8;

    #endregion

    #region variable

    /// StreamReader
    private StreamReader _reader;

    /// 属性の情報リスト
    private List<AttributePropertyInfo> _attirbuteInfos;

    /// データリスト
    private T[] _records;


    #endregion

    #region method

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public SimpleCsvReader(string directory, string fileName) {
        if (!Directory.Exists(directory)) {
            Directory.CreateDirectory(directory);
        }

        var filePath = directory + Path.DirectorySeparatorChar + fileName + ".csv";
        try {
            _reader = new StreamReader(filePath, FILE_ENCODING);
        } catch (Exception ex) {
            throw ex;
        }

        var targetType = GetType().GetGenericArguments()[0];
        _attirbuteInfos = targetType
            .GetProperties()
            .Select(x => new AttributePropertyInfo() {
                Property = x,
                CsvAttribute = x.GetCustomAttributes(typeof(SimpleCsvAttribute), false).FirstOrDefault() as SimpleCsvAttribute
            })
            .Where(x => x.CsvAttribute != null)
            .OrderBy(x => x.CsvAttribute.Order)
            .ToList();
    }

    /// <summary>
    /// 読み込み
    /// </summary>
    public T[] Read(bool hasHeader = true) {
        if (_reader == null) {
            return null;
        }

        if (hasHeader) {
            // ヘッダー分だけ進める
            _reader.ReadLine();
        }

        List<T> dataList = new List<T>();
        while (!_reader.EndOfStream) {
            var line = _reader.ReadLine();
            if (line == null) {
                break;
            }

            // 改行を考慮して行を読み込む
            while (!HasEnoughQuote(line)) {
                line += "\n" + _reader.ReadLine();
                if (_reader.EndOfStream) {
                    break;
                }
            }

            // 改行コードを排除する
            line = NEW_LINE_REGEX.Replace(line, "");

            // 要素分解を行う
            line += ",";
            var matches = DELIMITER_REGEX.Matches(line);
            var columns = matches.Cast<Match>()
                .Select(x => Dequote(x))
                .ToArray();

            // データを作成する
            var data = new T();
            for (int i = 0; i < columns.Length; i++) {
                var attribute = _attirbuteInfos[i];
                attribute.Property.SetValue(data, Convert.ChangeType(columns[i], attribute.Property.PropertyType));
            }
            dataList.Add(data);
        }

        return dataList.ToArray();
    }

    /// <summary>
    /// バイト配列で読み込む
    /// </summary>
    public byte[] ReadBytes() {
        if (_reader == null) {
            return null;
        }

        byte[] readBytes = null;
        using (MemoryStream memoryStream = new MemoryStream()) {
            _reader.BaseStream.CopyTo(memoryStream);
            readBytes = memoryStream.ToArray();
        }

        return readBytes;
    }

    /// <summary>
    /// 引用符が十分であるかどうか
    /// </summary>
    private bool HasEnoughQuote(string line) {
        return (QUOTE_REGEX.Matches(line).Count % 2) == 0;
    }

    /// <summary>
    /// 引用符を変換する
    /// </summary>
    private string Dequote(Match match)
    {
        var s = match.Groups[1].Value;
        var quoted = Regex.Match(s, "^\"(.*)\"$", RegexOptions.Singleline);

        if (quoted.Success)
        {
            return quoted.Groups[1].Value.Replace("\"\"", "\"");
        }
        else
        {
            return s;
        }
    }

    /// <summary>
    /// 破棄処理
    /// </summary>
    public void Dispose() {
        if (_reader != null) {
            _reader.Dispose();
        }
    }

    #endregion
}

ReadByteのメソッドは不要ですが、Csvをポストする際にByte配列にする必要があったので付け加えているだけです.
以上で、必要なクラスの定義は終了です.

利用例

では、実際に使ってみましょう.
Attributeを利用したデータクラスを作成し、SimpleCsvWriterにGenricで渡すだけです.

ProfileTest.cs
using System.IO;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// ProfileTest
/// </summary>
public class ProfileTest : MonoBehaviour
{
    #region define

    /// <summary>
    /// ProfileData
    /// </summary>
    public class ProfileData
    {
        [SimpleCsv(1)]
        public string Name { get; set; }
        [SimpleCsv(2, Name = "処理時間")]
        public int Time { get; set; }
    }

    #endregion

    #region method

    /// <summary>
    /// データを書き込む
    /// </summary>
    public void Write() {
        // 適当なテスト用のデータ作成
        var list = new List<ProfileData>(10);
        for (int i = 0; i < 10; i++) {
            list.Add(new ProfileData() {
                Name = i.ToString(),
                Time = (i * i)
            });
        }

        // 書き込み部分
        var directory = Application.persistentDataPath + Path.DirectorySeparatorChar + "Exports";
        using (var writer = new SimpleCsvWriter<ProfileData>(directory, "test", list.ToArray())) {
            writer.Write();
        }
    }

    /// <summary>
    /// データを読み込む
    /// </summary>
    public ProfileData[] Read() {
        ProfileData[] profiles = null;
        var directory = Application.persistentDataPath + Path.DirectorySeparatorChar + "Exports";
        using (var reader = new SimpleCsvReader<ProfileData>(directory, "test")) {
            profiles = reader.Read();
        }

        return profiles;
    }

    #region unity_script

    /// <summary>
    /// 開始処理
    /// </summary>
    private void Start() {
        // データ書き込み
        Write();

        // データ読み込み
        var profiles = Read();
        foreach (var profile in profiles) {
            Debug.Log(profile.Name + " = " + profile.Time);
        }
    }

    #endregion

    #endregion
}

最後に

Csvの入出力クラスはCsvHelperなどのThird Party製のものや、その他沢山の紹介記事がありますが、
痒いところに手が届かずこのようなクラスを作成することになりました.
この記事が誰かの一助になれば幸いです.

少し早いですが、今年もお疲れ様でした.
来年も頑張っていきましょう!

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

Unity Editor拡張 メモ

今回、とある実装でUnityのEditor拡張に触れる機会があり、そこで得られた知見をメモとして記事を書きたいと思います。

SerializedObject

Edito拡張でObjectを操作するために必要なのが「SerializedObject」です。
通常のInspectorから値を変更する際も実はこの「SerializedObject」を使っています。

// targetObjectをSirializedObjectに変換する
var serializedObject = new SerializedObject(targetObject);

Update

Updateは対象のObjectの最新の情報を取得するメソッドです。
何かしらSerializedObjectに変更を加える前にかならずUpdateをする必要がある。

serializedObject.Update();

ApplyModifiedProperties

Objectに変更点を適応させます。
基本Update => ApplyModifiedPropertiesの順番に記述します。

serializedObject.ApplyModifiedProperties();

FindProperty

Objectのプロパティを取得します。

var hoge = serializedObject.FindProperty("hoge");

プロパティが配列の場合にはGetArrayElementAtIndexを使って取り出すこともできます。

var index = 1;
var array = serializedObject.FindProperty("array").GetArrayElementAtIndex(index);

GUILayout

ボタンなどのUIを作成するときのクラス。

  • Button
  • Toggle
  • TextField
  • Toolbar

Button

if (GUILayout.Button("Button"))
{
     Debug.LogError("ボタン押した");
}

TextField

// TextFieldが変更されたにログが吐かれる
 using (var scope = new EditorGUI.ChangeCheckScope())
{
    _text = GUILayout.TextField(_text);
    if (scope.changed)
    {
        Debug.Log(_text);
    }
}

HorizontalScope

横の範囲を指定できます。
次の例ではUsingの中に記載したUIはHorizontalScopeの設定した範囲内に表示される。

// 幅を300f指定
using (new GUILayout.HorizontalScope(GUILayout.Width(300f)))
{
}

GUILayout.VerticalScope

縦の範囲を指定できます。

using (new GUILayout.VerticalScope(GUILayout.Height(50f)))
{
}

GUILayoutUtility

GUILayoutクラスの便利関数です。

GetRect()

Rectを取得します。

var rect =  GUILayoutUtility.GetRect(width,height)

GetLastRect();

直前のGUILayoutのRectを取得します。

// VerticalScopeのRectを取得する
using (new GUILayout.VerticalScope(GUILayout.Height(50f)))
{
    var rect = GUILayoutUtility.GetLastRect();
}

GUIContent

引数に渡したTextやTextureをUIとして表示させることができる。
ToggleやGenericMenuなどで利用できる

// GenericMenuでの使用例
var menu = new GenericMenu();
menu.AddItem(new GUIContent("メニュー1"), false, _ =>
{
    // メニュー押した時のAction
});


// ToggleにloopIconを表示させる
Texture loopIcon;
bool isOn;

GUILayout.Toggle(isOn, new GUIContent(loopIcon),"Toolbarbutton");

GUIStyle

GUIの見た目などを変更させるため利用するのがGUIStyleです
各UIパーツにGUIStyleを指定できる引数があります。

デフォルトで設定ができるStyleがあるのですが、これは文字列で指定する必要があります。
こちらのサイトに載っているのですが、見た目は実際に試してみてください
https://baba-s.hatenablog.com/category/Unity?page=1430097103

var textStyle = new GUIStyle("Label");
textStyle.normal.textColor = Color.white;
textStyle.fontSize = 10;
textStyle.alignment = TextAnchor.UpperLeft;

GUILayout.Label("GUIStyleで装飾できる", textStyle);

EditorGUI

エディタ拡張用のGUIクラス。

EditorGUI.DrawRect()

四角形を描画する

 var rect = new Rect(0.0f, 0.0f, 100f, 100f);
 EditorGUI.DrawRect(rect, Color.red);

image.png

EditorGUI.ChangeCheckScope()

using (var scope = new EditorGUI.ChangeCheckScope())
{
    GUILayout.Toggle();
    if (scope.changed)
    {
        // Toggleが切り替わったときに呼び出される              
    }
}

EditorGUILayout

EditorGUILayoutとGUILayoutはほぼ同じです。
現状同じことができるので好きな方を使ってよいと思いますが、EditorGUILayoutでしかできないこともあります。

EditorGUILayout.ScrollViewScope

スクロールの領域を指定できます。

private Vector2 _ScrollArea = Vector2.zero;

using (var scrollView = new EditorGUILayout.ScrollViewScope(_ScrollArea, GUILayout.Width(100f), GUILayout.Height(50f)))
{
      _ScrollArea = scrollView.scrollPosition;
}

LabelField

文字列を表示します。
第2引数でStyleを指定することもできます。

EditorGUILayout.LabelField(behaviourName, (GUIStyle) "MiniToolbarButtonLeft");

FloatField

floatの入力項目を作成します。

EditorGUILayout.FloatField(floatValue, GUILayout.Width(50.0f));

EditorGUIUtility

EditorGUIの便利関数。

FindTexture

指定したファイル名のTextureを取得します。
次の例ではUnityにデフォルトで設定されている画像を取得する処理です。

現在次のサイトの画像が取得できそうでう。
https://baba-s.hatenablog.com/entry/2017/12/01/164517

// PlayButton用のテクスチャを取得する
 EditorGUIUtility.FindTexture("PlayButton");

GenericMenu

自作のメニューを作成できます。
AddItemを使って文言と選択したときのコールバックを記述します。

var menu = new GenericMenu();
menu.AddItem(new GUIContent("選択名",false,_ =>
{
   //選択したときのコールバック
},"");

何かしらデータをコールバックへ渡したいときには第4引数に指定します。

Handles

本来はEditor上での3D描画を行うクラスなのですが、いろいろな図形を書くことができます。
単純な線を引く場合には次のようなコードを書きます。

Vector3 startPos;
Vector3 endPos;

Handles.DrawLine(startPos,endPos);

実際Editor拡張をする際にHandlesについてはこちらを参考にしました。
https://qiita.com/kyourikey/items/7a5f693d1fe17bde5387

Event.current

イベントを取得するときにEvent.currentを使います。

if (Event.current.type == EventType.MouseUp)
{
    // 左ボタン
    if (Event.current.button == 0)
    {
        Debug.LogError("左ボタンを押した");
    }
}

イベント取得範囲を決めたい場合は、次のように実装ができます。

// 判定
if (Event.current.type == EventType.MouseUp && Event.current.button == 1)
{
     var boxRect = GUILayoutUtility.GetLastRect();
     var mousePos = Event.current.mousePosition;
     if (boxRect.Contains(mousePos))
     {
         // イベントを使い終わった
         Event.current.Use();
     }
}

EventTypeはこちらを参考に
https://docs.unity3d.com/ja/current/ScriptReference/EventType.html

その他

GUI.backgroundColor

UIの背景色を変える。

// 赤色にする
GUI.backgroundColor = Color.red;

GUI.FocusControl

複数のUIからどれが選択されているか知ることができる。
各UIにSetNextControlNameで名前(key)を設定し、それを何かしらのイベントでFocusする。
Focusされた名前をGetNameOfFocusedControlで取得し、それを使いUIを変更します。

// コントロールする名前を設定する
GUI.SetNextControlName("Focus String");
// 指定した名前にフォーカスする
GUI.FocusControl("Focus String");
// フォーカスされている名前を取得する
var focusString = GUI.GetNameOfFocusedControl()
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【C#】C#でつまづいたところを「なるほどなっとくC#入門」読みながら再入門

C# Advent Calendar 2020

この記事は C# Advent Calendar 2020 の 13日目の記事です。

はじめに

私はXR関連の開発のお仕事をしています。
もともとUnityきっかけでC#を触り始め、それが一番最初に触れたプログラム言語でした。
C#の基礎から学ぶというよりか、「UnityでこれをするにはC#のコードはこう書けば動く」
といった解釈を繋ぎ合わせてプログラムを書いてきました。

それでちゃんとお金ももらえていたので不自由はそこまでなかったのですが、
転職してよりレベルの高い環境に身を置いて感じたのは、
人のコードを読んだり、きれいに設計してコードを書くような場面では基礎が無いと辛い 
ということでした。

まとめると、自分の中で基礎の理解が乏しいと感じたので読みました。

なるほどなっとくC#入門

C#の入門書です。

【リンク】:なるほどなっとく C#入門

ベースとなる知識が無い状態で読んでいたらきっと一発で理解はできてないだろうな、、、
という内容もありましたが、
一通りC#で何かしら開発した人ならスラスラ読めると思います。

具体例が多く、とてもわかりやすい本でした。

オブジェクト指向


オブジェクトとは

データ(属性)手続き(振る舞い)を1つのまとまりとして考えたもの

例)
オブジェクト:車
データ(属性):車種名、排気量など
手続き(振る舞い):前進、停止など


オブジェクト指向の利点

細かで面倒くさいことを忘れてしまえること


カプセル化

複雑なことはすべてオブジェクトの中に隠してしまうことができる

オブジェクト指向の概念の1つである、カプセル化、よく聞きますよね。

中身を知らなくても手続き(振る舞い)を知っていれば
オブジェクトの操作ができような状態を指します。

車の構造に詳しくない人間でも
「これは車!だからアクセル踏むと前進ができる」ってな感じで運転ができるような状態が
カプセル化ってことです。


関連データを1つにまとめる

Zennに公開されていたポケモンの例えが非常にわかりやすかったので
ここでも同様の例えを用いようと思います。

2020年12月現在、ポケモンの総数は898匹いるそうです。
【参考リンク】:全国ポケモン図鑑順のポケモン一覧

このポケモンたちそれぞれの能力値をプログラムで登録する際に、
下記の具合でひたすら898個の変数を用意するのはとてつもない作業になってしまいます。

string なまえ1;
int HP1;
int こうげき1;
int ぼうぎょ1;
int とくこう1;
int とくぼう1;
int すばやさ1;

string なまえ2;
int HP2;
int こうげき2;
int ぼうぎょ2;
int とくこう2;
int とくぼう2;
int すばやさ2;

...

これらの変数は「ポケモン」というくくりで1つにまとめた方が良さそうです。

クラス

1つにまとめてポケモンという概念を表現するためにクラス(class)を作ります。

class Pokemon
{
    public string なまえ;
    public int HP;
    public int こうげき;
    public int ぼうぎょ;
    public int とくこう;
    public int とくぼう;
    public int すばやさ;
}

オブジェクト(インスタンス)の生成

先ほど定義したPokemonクラスを利用して複数のポケモンを定義することができます。
そのためにはインスタンスを生成します。
下記はインスタンス生成と初期化を同時に行った例です。

Pokemon pokemon1 = new Pokemon
{
    string なまえ = ピカチュウ;
    int HP = 35;
    int こうげき = 55;
    int ぼうぎょ = 40;
    int とくこう = 50;
    int とくぼう = 50;
    int すばやさ = 90;
}

Pokemon pokemon2 = new Pokemon
{
    string なまえ = ライチュウ;
    int HP = 60;
    int こうげき = 90;
    int ぼうぎょ = 55;
    int とくこう = 90;
    int とくぼう = 80;
    int すばやさ = 110;
}

このように、クラスを使えば同じ特徴を持っていながら、
それぞれ中身は別々のオブジェクトを作成
することができます。

継承

あるクラスの性質を受け継いで新しいクラスを作成すること

継承は派生とも呼ばれている

継承もオブジェクト指向に欠かせない要素の1つです。

まず基底クラス(スーパークラス)派生クラス(サブクラス)を知る必要があります。

例を挙げると、
ピカチュウ(派生クラス)はポケモン(基底クラス)であるとなります。

クラスの関係図(クラス図)を書くと下記のようになります。

ぽけもん図.png

私は初見で矢印逆では?と思いましたが、これで合っています。
基底クラスは派生クラスを知りません。
矢印の向きはピカチュウクラスがポケモンクラスを知っていることを表しているわけですね。


差分プログラミング

基底クラスと派生クラスに違いを持たせることを差分プログラミングと呼ぶそうです。

ピカチュウだけ登録した人語を喋る機能があったとしましょう。
他のポケモンは人語を喋りません。
下記がその例です。

class Pokemon
{
    string なまえ;
    int HP;
    int こうげき;
    int ぼうぎょ;
    int とくこう;
    int とくぼう;
    int すばやさ;
}


class Pikachu : Pokemon
{
    string セリフ;
}

派生クラスのPikachuクラスでは基底クラスとの違いのみを定義しています。


is a 関係

is a 関係が成り立たないときは、継承を使ってはなりません

これは、例えばサトシがポケモンクラスを継承するのはおかしいので
間違っているよということです。
サトシ is ポケモン が成り立たないからです。

前述の差分プログラミングの考えだけ取り入れて、
共通部分があるから基底クラスを作って派生させよう!だけではダメってことですね。

ポリモーフィズム

日本語に直訳すると多態性、多相性という意味らしいです。

ポリモーフィズムにより、
ゼニガメだろうが、ヒトカゲだろうが
ポケモンという同一のオブジェクトとして見なしたうえで
それぞれに応じた攻撃の処理を呼び出すことができます。

ポリモーフィズムの考えを取り入れれば不要な条件分岐を排除できます。

ポリモーフィズムを取り入れないコードの場合は
このポケモンはゼニガメだから攻撃時この処理をして、
このポケモンはヒトカゲだから攻撃時この処理をして、、、

というのを898回、if文やswitch文を書かなくてはなりません。

public void Attack()
{
    if(ポケモンの名前 == ゼニガメ)
    {
        //ゼニガメの攻撃
    }
    else if(ポケモンの名前 == ヒトカゲ)
    {
        //ヒトカゲの攻撃
    }
    else if(...)
    .
    .
    .
}


【参考リンク】:継承とポリモーフィズム


次に、継承によるポリモーフィズムを導入した例を見ていきます。

まずは基底クラスとなるポケモンクラスを定義します。

class Pokemon
{
    public virtual void Attack()
    {
    }
}

virtualキーワードをメソッドにつけると派生クラスで中身を上書きすることができます。

基底クラスに具体的な攻撃の処理は書かずに、派生クラスで具体的な攻撃の処理を実装することで
派生クラスごとに独自の攻撃を行わせることが可能となります。

派生クラスでvirtualキーワードのついたメソッドを上書きする際には
overrideキーワードを使用します。

class Hitokage : Pokemon
{
    public override void Attack()
    {
        //ヒトカゲの攻撃処理
    }
}


class Zenigame : Pokemon
{
    public override void Attack()
    {
        //ゼニガメの攻撃処理
    }
}

上記クラスのAttackメソッドを利用する際には下記のように記述できます。

Pokemon p1 = new Hitokage();
Pokemon p2 = new Zenigame();

p1.Attack(); //ヒトカゲの攻撃処理が実行される
p2.Attack(); //ゼニガメの攻撃処理が実行される

一見するとPokemonHitokage,Zenigameは型が違う変数同士なので
コンパイルエラーになりそうですが、格納が可能です。

継承関係がある場合においては、派生クラスのオブジェクト(インスタンス)を
基底クラスの変数に代入することが可能です。

つまり、Hitokage,ZenigamePokemonとして同一視したうえで
同じ名前であるAttackメソッドをそれぞれ別々のメソッドとして呼び出せるようになっている
ということです。

これがポリモーフィズムらしいです。

抽象クラス、抽象メソッド

ポリモーフィズムの説明で、

基底クラスに具体的な攻撃の処理は書かずに、派生クラスで具体的な攻撃の処理を実装する

という表現をしました。

ポリモーフィズムの説明の例では基底クラスに具体的な処理は書かないので、
通常、インスタンスを生成することもありません。
下記のようにポケモンクラスのインスタンスを生成して使うことはないということです。

Pokemon p = new Pokemon();

表現を変えると、ポリモーフィズムの説明の例において
基底クラスは概念を表しているクラスなので
継承されることを前提とした抽象的なクラス
です。

今説明したようなクラスのことを抽象クラスと言います。
abstractキーワードを付けることで抽象クラスとなります。

メソッドも同様に抽象的な状態で中身を定義せず、継承先で具体的に実装するものが存在します。
それを抽象メソッドと言います。
抽象メソッドは継承先で必ず実装する必要があります。

abstract class Pokemon
{
    public abstract void Attack();
}

さいごに

結論言うと、読んで正解でした。
まず、当然ですが賢くなります。これは当たり前ですね。
もう1つプラスとなる要素として、モチベーションが上がりました。

エンジニアになる前に入門書を読んだ際は
難しすぎて本を噛みちぎってやろうかと何度も思いましたが、
今読むと、ある程度サクサクと理解できるようになっていました。

本を噛みちぎりたくなっても、諦めずに
その段階で理解できるところだけでも知識として拾っていくというのを繰り返せば
例えその成長曲線が緩やかだったとしても、
"ちゃんとレベルアップできるんだな"
ということが確認でき、自信が持てました。

基本構文から丁寧に説明されている良い本なので
おススメです。

【リンク】:なるほどなっとく C#入門

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

'Screen Space - Overlay'なCanvasでのRectTransform (Unity uGUI)

前提

検証環境
  • Unity 2019.4.x (LTS)

概念

スクリーンショット 2020-12-07 123044.png

  • 青色のTargrtオブジェクトに着目します。
    • RectTransformは、Transformの派生クラスです。
      • MonoBehaviorを継承したクラスがRectTransformを持つなら、(RectTransform) transformあるいはtransform as RectTransformで、RectTransformにアクセスできます。
        • RectTransformがない場合、キャストは例外を発生し、asnullを返します。
        • 例外が発生しなければキャストの方が高速です。
    • ワールド座標系
      • Screen Space - OverlayCanvasでのワールド座標系は、スクリーン座標系に一致します。
    • ローカル座標系
      • Transformは、親のローカル座標系で配置されます。
      • RectTransform.rectは自身のローカル座標系で示されます。
      • RectTransformのメンバーはローカル座標系に依存しているので、スケールの影響を受けます。
        • rectsizeDeltaなどからワールド/スクリーン座標系の実サイズを得るためには、lossyScaleを適用する必要があります。
    • 正規化座標系
      • 基準とする矩形の左下を(0.0,0.0)、右上を(1.0,1.0)とする座標系です。
      • 負数や1.0を超える値も有効で、矩形の外が表現できます。
    • parent
      • 赤色の矩形は、ヒエラルキーで親になるParentオブジェクトのrectです。
      • 個々のオブジェクトは、親の影響下で、自身のローカルな系を持ちます。
    • anchor
      • 図中の、白い楔形が指し示す4点です。
        • 4点が全て一致して1点となる場合や、2点ずつ一致して2点となる場合もあります。
      • オブジェクトは、これらの箇所で親に癒着していて、親のrectの変化に追従します。
      • 親のrectを基準として正規化された座標系で、左下anchorMinと右上anchorMaxの位置が規定されます。
    • pivot
      • 図中の、青いドーナッツ型の中心点です。
      • 自身のローカル座標の原点で、オブジェクトの位置を代表します。さらに、回転軸でもあります。
      • 自身のrectを基準として正規化された座標系で位置が規定されます。
    • rect
      • 図中の、青丸の角と白線で示されている矩形領域です。
      • オブジェクトの領域を表します。
      • anchorMinから左下角へのオフセットoffsetMinと、anchorMaxから右上角へのオフセットoffsetMaxとして規定されます。
    • scale
      • 親に対する相対的な拡大率です。
      • ワールドに対しては、ヒエラルキーのルートから積算されたスケールを持つことになります。
    • rotation
      • 親に対する相対的な回転角です。
      • ワールドに対しては、ヒエラルキーのルートから加算された回転角を持つことになります。

変数

RectTransform 意味 規格
anchoredPosition pivotの位置$^{※}$ anchorからの変位 Vector2
anchoredPosition3D pivotの位置$^{※}$ anchorからの変位 Vector3
pivot pivotの位置 rectの正規化座標系 Vector2
anchorMax anchorの右上位置 親のrectの正規化座標系 Vector2
anchorMin anchorの左下位置 親のrectの正規化座標系 Vector2
offsetMax rect右上角の位置 anchorMaxからの変位 Vector2
offsetMin rect左下角の位置 anchorMinからの変位 Vector2
rect rectの位置とサイズ 自身のローカル座標系 Rect
sizeDelta rectのサイズ anchorとのサイズ差 Vector2
Transformから継承 説明 規格
localEulerAngles 回転角度 親のローカル座標系 float
localRotation 回転四元数 親のローカル座標系 Quaternion
localPosition pivotの位置 親のローカル座標系 Vector3
localScale 拡大率 親のローカル座標系 Vector3
eulerAngles 回転角度 ワールド座標系 float
rotation 回転四元数 ワールド座標系 Quaternion
position pivotの位置 ワールド座標系 Vector3
lossyScale 拡大率 ワールド座標系、読み取り専用 Vector3

※anchorからの変位によるpivotの位置

  • anchoredPositionは、anchorMinanchorMaxが一致しない(つまりanchorが一点でない)場合は、pivotの位置を示せなくなる可能性があります。
    • 例えば、親がrect=(0,0,200,200)で、対象がanchorMin=(0.5,0.5)anchorMax=(1,1),sizeDelta=(0,0)のとき、pivotを何処に移動してもanchoredPosition(0,0)のまま変化しません。(ただし、計算精度による微少変動を除きます。)

Public 関数

RectTransform 説明
ForceUpdateRectTransforms 強制的に内部データを再計算します。
GetLocalCorners rectの頂点座標を自身のローカル座標系で取得します。
GetWorldCorners rectの頂点座標を自身のワールド座標系で取得します。
SetInsetAndSizeFromParentEdge 親のrectの一辺からの距離とサイズでrectの一軸を設定します。
SetSizeWithCurrentAnchors rectの一軸のサイズを設定します。

GetLocalCorners

public void GetLocalCorners (Vector3 [4] corners);

  • 四頂点を格納可能な配列を渡して矩形の座標を得ます。

SetInsetAndSizeFromParentEdge

public void SetInsetAndSizeFromParentEdge (RectTransform.Edge edge, float inset, float size);

  • 一回のコールで、矩形の一軸の領域を設定します。
    • pivotの値を満たすように、領域が展開されます。
    • 矩形を定めるためには、軸を変えて二回コールしなければなりません。
    • 同じ軸に属する対辺で二回目をコールすると上書きされます。
  • edge
    • RectTransform.Edge.BottomLeftRightTop
    • 親の矩形の四辺から一つを選択します。
  • inset
    • 領域の開始ポイントです。
  • size
    • inset + sizeが、領域の終了ポイントです。

SetSizeWithCurrentAnchors

public void SetSizeWithCurrentAnchors (RectTransform.Axis axis, float size);

  • 一回のコールで、矩形の一軸のサイズを設定します。
    • pivotの値を満たすように、領域が展開されます。
    • 矩形を定めるためには、軸を変えて二回コールしなければなりません。
  • axis
    • RectTransform.Axis.HorizontalVertical
    • 軸を選びます。
  • size
    • 軸の領域サイズです。

エディタの挙動

  • ツールバー
    • Pivot/Center
      • 選択によって、Sceneウインドウで回転させる際の中心軸が変わります。
      • pivotrect中央にないときにCenterを選んでシーン・ウインドウで回転させると、rotation以外のメンバーも変化します。
  • インスペクター
    • anchorMinanchorMaxが一点で一致する場合は、(posX,Width)(posY,Height)を編集可能です。
    • anchorMinanchorMaxが一致しない軸においては、(Left,Right)あるいは(Top,Bottom)が編集可能です。
    • RightanchorMax.xから、TopanchorMax.yからの、負のオフセットです。
    • LeftanchorMin.xから、BottomanchorMin.yからの、正のオフセットです。

CanvasScaler

  • CanvasScalerCanvasと共にアタッチされているRectTransformlocalScaleは、Canvas.scaleFactorと同じ値を示します。
    • 子孫のRectTransformはその影響下にあり、そのlossyScaleは、Canvasまで順に親を辿ってlocalScaleを積算したものに一致します。
  • つまり、CanvasScalerによるスケーリングは、RectTransform.localScaleを制御しているだけなので、単なるRectTransformの挙動として捉えることができます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UHF帯RFIDを用いた入退室および出退勤記録

システム概要

UNIQLO や図書館, 倉庫での在庫管理等によく用いられコンビニへの導入検討で最近注目されている UHF 帯 RFID を使用した簡単な入退室および出退勤を記録するシステムを作成.

機能

  1. 常時, 出入口付近に設置された 2つ以上のアンテナがタグ(人物)の監視を行い, 入退室の記録を行う. この時点での打刻は行わない.
  2. 入退室記録をもとに cron コマンドによりユーザデータ及び出退勤(打刻)情報の記録

構成図

構成図

  • リーダライタ(Impinj R420), アンテナ
    リーダに接続するアンテナは 1(内側), 3(外側) の 2つ. 人が通過した際, 2つのアンテナでタグが検出されないように出力及び設置位置を調整.
    1 -> 3 の順でタグを検出した場合, 退室
    3 -> 1 の順でタグを検出した場合, 入室
    天井や壁に設置してお互いの電波が干渉しないように調整.
  • PC, データベース
    データベースには人事労務 API より取得した従業員データに重複しないタグ ID を紐づけてデータを格納.
    履歴テーブルに何処に設置したか識別できるよう読込を行ったリーダ ID と従業員 ID, ステータス(Entry, Leaving), 通過日時を格納.
  • freee API

設置結果

100% までとは行かないが, ある程度正確に入退室データの記録ができ打刻への追加に成功.

問題点

ある程度動作を行い生じてきた問題を列挙.

  • 出勤時の入室, 退勤時の退室が記録されていない最悪の状態
    タイムレコーダに記録する際の基準となる情報が取得できていない. そのため信頼できないデータになっている.
  • 入退室履歴のステータスに同じものが連続する
    入退室どちらかのデータに欠損が生じ, 入室および退室が連続してしまった.

改善点など

  • 正確に出退勤を記録できていない場合の通知.
  • 上記の状態でブラウザからの申請を可能にする.
  • あまりあっていはいけないが, 日を跨いでの退室(退勤)を検出した場合を考慮.
  • cron コマンドを多用せずプログラム中でトークンの更新を行う.

Appendix

systemctlService などで動作させることを目的にしていなかったため, すごく散らかってますがソースコード.
freee API タイムレコーダ(打刻)機能との同期については, 上述の改善点に修正を加えて追加する予定(エターナるかもしれません).
/// WARNING: cron コマンドによりアクセストークンの更新を行っているため, 若干半自動.
entleaving - github

機材や RFID について

使用した機材と注意点, RFID について記載します.

UHF 帯 RFID リーダライタ - Impinj R420

Impinj 社製 UHF 帯 RFID リーダライタです.
電源に AC アダプタもしくは Power over Ethernet(PoE) が使用できます.
Low-Level Reader Protocol(LLRP) を使用して開発を行います.

!! 本記事で使用しているリーダの出力は 1[W] であるため電波申請が必要となります. !!
特定小電力無線局である 250[mW] 以下のリーダライタの場合は不要となります.

Radio Frequency IDentification(RFID)

本記事で使用した RFID は UHF 帯 RFID であり, 通信には電波方式を用いています. この RFID の特徴は長距離での通信が可能であることと複数のRFIDタグを同時に読み込めることです.

日常で使用している RFID として, Suica や PASMO 等が挙げられます.
これらの RFID は NFC の一種であり, 電磁誘導方式での給電とデータ通信を行っているため名称の如く近接距離(最大でも 10[cm] 程)となっております.

Impinj R420 RFID Tag
Impinj_R420.jpg RFID_TAG.jpg

システム構成

名称 備考
言語 C# .NET5, Version 9.0
ライブラリ LLRP 10.40.0
Newtonsoft.Json 12.0.3
Npgsql 5.0.0
Dapper 2.0.78
データベース PostgreSQL 13.1
UHF RFID リーダ Impinj R420

データベース(テーブル)構成

create extension if not exists "uuid-ossp";

/** リーダテーブル - リーダに対応する設定は別ファイル
 */
create table if not exists readers(
  id            uuid                                               not null,
  hostname      character varying(256)                             not null,
  location_name character varying(128),
  remarks       character varying(128),
  created_at    timestamp with time zone default CURRENT_TIMESTAMP not null,
  primary key(id)
);


/** 従業員テーブル
 */
create table if not exists employees(
  id         integer                                            not null,
  tag_id     character varying(64),
  created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
  primary key(id)
);

/** (入退室)履歴
 */
create table if not exists histories(
  id          bigserial                                          not null,
  reader_id   uuid                                               not null references readers(id),
  employee_id integer                                            not null references employees(id),
  status      smallint                                           not null,
  created_at  timestamp with time zone default CURRENT_TIMESTAMP not null,
  primary key(id)
);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フォトビューワーみたいなのを作った話

なんで作ったの?

画像、特に縦長の画像を分割したい。

例えば、

a.png
(いらすとや画像)
https://www.irasutoya.com/p/terms.html

みたいな画像がある時、

a.png

a.png

に分けて表示する。

今の例では、上半身の画像を表示しているときに次の画像へ移動すると、下半身の画像になる。

細かいことは?

画像があまり荒く表示されるのも嫌なので、表示する高さの最低値は500。

ceil(画像の高さ / 定数) を分割数を決定する。
ここでは定数を400とした。
ここで計算した分割数が2の時は、分割数が確定する。

分割数が2の時

画像の高さが400~500の場合があり、高さの最低値を満たせない。
画像の高さ < 400 * 1.5の場合、分割数1の処理をする。
そうでない場合、分割数が2以上の時の処理をする。

分割数が1の時

高さの最低値を無視して、幅・高さそれぞれを2倍にした画像を表示。

分割数が2以上の時

元画像を切る長方形を仮決定する。

height_ = Math.Max(表示する高さの最低値, img.Height / 分割数)
{
 x: 0
 y: (img.Height - rect.Height) * curDivNum / (分割数 - 1)
 width: img.Width
 height: height_
}
x, y, width, heightは元画像基準である。

このプログラムのウィンドウの幅の候補 := このプログラムのウィンドウの高さ * rect.Width / rect.Heightと仮決定する。

if (このプログラムのウィンドウの幅の候補 > このプログラムのウィンドウの実際の幅) なら、アスペクト比に気をつけながら、実際の幅に収める。

フォルダ選択

プログラム起動時に、ピクチャディレクトリを表示。

以下のように表示。
ただし、以下ではピクチャディレクトリ直下のフォルダを表示しない。

image.png

カーソルを上に持っていくとこのようになる。
dummy 1と表示しているのは。このディレクトリのpathあら.../Picturesまでのパスを消して、'\'を' 'に置き換えて表示している。

image.png

fugaの画像部分、もしくはテキスト部分をクリックした場合、以下のように表示される。

image.png

カーソルを上に持っていくとこのようになる。

image.png

hogeフォルダを開くと、このようになる。

image.png

(サブディレクトリの)^* サブディレクトリを表示できる。

表示順

同画像の分割は上から下だが、ファイルの順番は作成日時、更新日時、最終アクセスタイム、ランダムスタート(更新日時ソートの順列のランダムな位置から順番に表示)、シャッフルがある。
(”日時”は方法が分からない。知っている人がいれば教えてくれると嬉しいです)

また、逆順表示機能もある。

トリミング

トリミングボタンを押すと、画像に割り当てられているプログラムが現在表示している画像を開く。

windows10で何もしていなければ、windowsフォトが開く。
フォーカスが戻ると、更新日時が元画像の更新日時の+900m、または-900msになる。

bestフォルダから移動、bestフォルダに移動

"→"ボダンがあり、このボタンをbestフォルダの画像を表示している時に押すと、from_bestフォルダに送られる。
bestフォルダ以外で押すと、bestフォルダに移動。

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

MiniScriptでつくる「メッセージ再生&入力待ち」コマンド

はじめに

Unityで色々とゲームを作っていると、たまに
「RPGやADVで見るような会話イベント処理をつくりたい」
といった事を思うことはないでしょうか。えっわたしだけ?
会話イベントでは「メッセージの再生」「キー入力待ち」などの処理の流れがよく出てきます。
それを実装しようと思った時通常のC#スクリプトで無理に頑張ろうとすると

yield return mes("ほげほげ"); // メッセージ表示&キー入力待ち
yield return mes("アヘアヘ"); // 同上
...以下色々会話イベントの処理...

みたいな、yield returnをつけないといけない仕組みになったりします。いちいち書くのは正直つらいですね。

どうせテキストで書くならこれくらいシンプルにしちゃいたいところです。

mes "ほげほげ" # メッセージ表示&キー入力待ち
mes "アヘアヘ"
...以下色々...

なお、会話シーンを実装するのに役立つアセットとして

  • ノードを使ったり、Unity上でコマンドを入れていくFungus
  • Excelでシナリオデータを記述する 宴
  • テキストベースで吉里吉里やティラノスクリプト寄りの構文で書ける JokerScript

など色々とありますが・・・

今回は、そこまで高機能なアセットは要らないんだけどテキストベースのスクリプトを使ったゲーム中のシナリオ再生/イベント作成の仕組みをある程度手軽に組み込みたい…といった人向けに
「MiniScript」という、組み込み型のスクリプト言語を用いた「メッセージ表示&キー入力待ち」の方法についてざっくり解説します。

(結構ニッチな気がする…)

MiniScript

オープンソースのC++, C#向け組み込みスクリプト言語です。

https://miniscript.org

比較的シンプルな初期命令セットと括弧の無い構文で構成されており、習得が容易な部類の言語となっています。

こちらアセットストアで有料アセットとして販売されていますが、言語のコア部分はgithubからダウンロードしてすぐに使用することができます。

有料アセットの方に含まれているのは「ゲーム中にスクリプトを編集できる、補完機能搭載のインゲーム用エディタ」です。コア機能的にはいっしょ(のはず)

今回はとりあえずコア部分だけ使えればよいので、githubからソースをダウンロードして組み込みを行います。

解説プロジェクトについて

Unity 2019.4.13f1 で動作確認しています。

MiniScriptの組み込み

まずgithubからソース一式をダウンロードします。

https://github.com/JoeStrout/miniscript

解凍して「MiniScript-cs」の中身から必要なソースコード(画像参照)を取り出してUnityプロジェクトのどこかにコピーします。
メインプログラムコードやソリューションファイルは不要なため今回は省きます。
image.png

これでプロジェクトへの組み込みは完了です。
ちなみに、「MiniScript-cs」ディレクトリをまるごとUnityプロジェクトに放り込んでも一応問題なく動きます。

実行環境の作成

MiniScriptのスクリプトを実行するには、実行環境にあたるインタプリタを生成、管理する必要があります。

とりあえず今回はお試し感覚で大雑把につくりましょう。
以下のスクリプトを適当な場所に置きます。

using Miniscript;
using UnityEngine;

public class MiniScriptPlayer : MonoBehaviour {
    private readonly Interpreter _interpreter = new Interpreter();

    private void Start() {
        _interpreter.hostData = this;
        _interpreter.standardOutput = Debug.Log;
        _interpreter.implicitOutput = s => Debug.Log($@"implicit {s}");
        _interpreter.errorOutput = Debug.LogError;

    // スクリプトテキストの入力
        _interpreter.Reset($"print \"Hello World!\"");
    // 入力したスクリプトの実行を開始
        _interpreter.Compile();
    }

    private void Update() {
        if (!_interpreter.Running()) return;

        // スクリプトが終了する、または途中で中断(yield)されたり、一定の処理時間が経過するまで処理が実行されます
            _interpreter.RunUntilDone();
        }
    }
}

適当なGameObjectにこのスクリプトをアタッチしてゲームを起動すると、文字列で入力されたMiniScriptのテキストが実行されます。

実行後、コンソールに以下のログが出ていれば正しく実行されています。

Untitled2.png

スクリプト入力口の作成

インスペクタからスクリプトを入力できるようにしてみます。

実際に運用するときはスクリプトはテキストファイルなどにするものですが、今回は楽な方に走ります。
MiniScriptPlayerクラスを以下のように変更します。

using Miniscript;
using UnityEngine;

public class MiniScriptPlayer : MonoBehaviour {
    private readonly Interpreter _interpreter = new Interpreter();

    [Multiline(7)] public string _scriptField; // 追加

    private void Start() {
        _interpreter.hostData = this;
        _interpreter.standardOutput = Debug.Log;
        _interpreter.implicitOutput = s => Debug.Log($@"implicit {s}");
        _interpreter.errorOutput = Debug.LogError;

        _interpreter.Reset(_scriptField); // 変更
        _interpreter.Compile();
    }

    private void Update() {
        if (!_interpreter.Running()) return;

        // スクリプトが終了する、または途中で中断(yield)されたり、一定の処理時間が経過するまで処理が実行されます
        _interpreter.RunUntilDone();
    }
}

作成した入力口に以下のスクリプトを入力して実行してみましょう。

print "Hello World!"
wait 1
print "a"
wait 1
print "b"
wait 1
print "c"

wait コマンドは入力した秒数分スクリプトの実行を待機するものです。
実行後、コンソールに1秒ずつログが流れれば成功しています。

Untitled3.png

コマンドの自作

本題となる、コマンドの自作をやってみましょう。

まずはコマンドの仕様をざっくり策定します。
今回UIまわりにまでは手を出しません。
そのため、最低限テキストに入力したメッセージが見えればよいということで以下のような仕様としました。

  • コマンド名は mes
  • 引数は文字列ひとつ
  • 返り値は無し
  • 実行されるとDebug.Logにメッセージが流し込まれる
  • メッセージ表示後、マウス左クリックで次のコマンドに進む

Intrinsic.Create() メソッドでコマンドを作成することができます。
コマンド名は前述の通り mes とするため、引数に mes と入れます。

var f = Intrinsic.Create("mes");

次にコマンドの引数を設定します。

スクリプト上だと露出しませんが、内部処理のために引数に名前をつける必要があります。
今回は適当に text としました。

var f = Intrinsic.Create("mes"); 
f.AddParam("text", string.Empty); // 第二引数は コマンド引数textのデフォルト値

メッセージ再生

ここからコマンド内部の処理を作成していきます。

Create時に返ってきたIntrinsicの code に実際の処理を記述します。

var f = Intrinsic.Create("mes"); 
f.AddParam("text", string.Empty);
f.code = (context, result) => {
    // ここに色々書く
};

まずは先程策定した引数 text をコンソールに表示する処理を書いてみます。

f.code = (context, result) => {
  Debug.Log(context.GetVar("text").ToString());
  return Intrinsic.Result.Null;
};

context.GetVar()でスクリプト側で入力された引数textの値を拾いコンソールログに送っています。

return の Intrinsic.Result.Null についてですが、
各コマンドの処理は何かしらの Result インスタンスを返す必要があります。
前述の仕様の通り、今回のコマンドでは返り値を無しとするため
MiniScript側に用意されている Null Result インスタンスを使用して返しています。
(空文字列を返したり真偽値を返したりといくつか既定の種類がありますが、Resultの中身は自分で色々設定する事もできます)

この時点で、mes コマンドを用いたコンソールログの出力が可能になりました。
MiniScriptPlayerを以下のようにして、スクリプト側で mes コマンドを使ってみましょう。

using Miniscript;
using UnityEngine;

public class MiniScriptPlayer : MonoBehaviour {
    private readonly Interpreter _interpreter = new Interpreter();

    [Multiline(7)] public string _scriptField;

    private void Start() {
        _interpreter.hostData = this;
        _interpreter.standardOutput = Debug.Log;
        _interpreter.implicitOutput = s => Debug.Log($@"implicit {s}");
        _interpreter.errorOutput = Debug.LogError;

        var f = Intrinsic.Create("mes"); 
        f.AddParam("text", string.Empty);
        f.code = (context, result) => {
            Debug.Log(context.GetVar("text").ToString());
            return Intrinsic.Result.Null;
        };

        _interpreter.Reset(_scriptField);
        _interpreter.Compile();
    }

    private void Update() {
        if (!_interpreter.Running()) return;

        // スクリプトが終了する、または途中で中断(yield)されたり、一定の処理時間が経過するまで処理が実行されます
        _interpreter.RunUntilDone();
    }
}

print コマンドと合わせて使っても同じようにうごきます。

Untitled4.png

キー入力待ち

それでは、最後に「キー入力待ち」を実装しましょう。

現状 mes コマンドで最終的に Null の Result を返すとコマンドが終了してしまいます…が、
Result は、返り値の他に「このコマンドの処理が終了したかどうか」をシステムに返す事もできます。

処理が終了していないと返せば、スクリプト全体の実行が一時中断(yield)され、次のフレームで再度該当のコマンドが再実行されるようになります。

MiniScript側に用意されている Intrinsic.Result.Waiting を用いる事でその挙動を取ることができるようになります。
試しに mes コマンドの実装の返り値を Waiting に変えてみましょう。

f.code = (context, result) => {
    Debug.Log(context.GetVar("text").ToString());
    return Intrinsic.Result.Waiting; // 変更
};

以下のような状態になるはずです。無限にログが流れますね。

Untitled5.png

コマンドの再実行が行われるという仕様なので、素直に上記のようにしただけだと無限にDebug.Logが呼ばれ続けてしまいます。

これをどうにかするには、コマンド実行の最初のフレームでのみログ表示を行う…といった処理にする必要があります。

Waitingで返したコマンドが再実行される際は、処理の第二引数である result に値が入ってくるようになっており中断時の状態に合わせた処理を書くことができます。

この仕様を利用し、以下のように処理を変更します。

f.code = (context, result) => {
    if (result == null) {
        Debug.Log(context.GetVar("text").ToString());
    }
    return Intrinsic.Result.Waiting;
};

すると、コンソールログへの "b" の表示が一つだけになります。

さて、後もう一息です。
ログは正しく出るようになりましたが、今のままだと一生コマンドが終了しないので先に進むことができません。
マウスクリックを検知したら Null リザルトを返すように処理を修正しましょう。

f.code = (context, result) => {
    if (result == null) {
        Debug.Log(context.GetVar("text").ToString());
    }
    else {
        if (Input.GetMouseButtonDown(0)) {
            return Intrinsic.Result.Null;
        }
    }

    return Intrinsic.Result.Waiting;
};

動きがわかりやすいようにMiniScriptのスクリプトも少し変更しましょう。

mes "Hello World!"
mes "a"
mes "b"
mes "c"

これで実行し、マウスの左クリックを行うごとにログが出てくるようになれば完成です!

後はDebug.Logなところを自作のメッセージ表示処理に変えてやるなり、ゲームに合わせて改造していく事でそれっぽいコマンドを作り上げることができるようになります。

全体図

今回作成したクラスは再生環境+コマンド含めて以下な感じになりました。

using Miniscript;
using UnityEngine;

public class MiniScriptPlayer : MonoBehaviour {
    private readonly Interpreter _interpreter = new Interpreter();

    [Multiline(7)] public string _scriptField;

    private void Start() {
                // ログ出力先など初期設定
        _interpreter.hostData = this;
        _interpreter.standardOutput = Debug.Log;
        _interpreter.implicitOutput = s => Debug.Log($@"implicit {s}");
        _interpreter.errorOutput = Debug.LogError;

                // mes コマンド定義
        var f = Intrinsic.Create("mes");
        f.AddParam("text", string.Empty);
        f.code = (context, result) => {
            if (result == null) {
                Debug.Log(context.GetVar("text").ToString());
            }
            else {
                if (Input.GetMouseButtonDown(0)) {
                    return Intrinsic.Result.Null;
                }
            }

            return Intrinsic.Result.Waiting;
        };

                // スクリプトコンパイル、実行
        _interpreter.Reset(_scriptField);
        _interpreter.Compile();
    }

    private void Update() {
        if (!_interpreter.Running()) return;

        // スクリプトが終了する、または途中で中断(yield)されたり、一定の処理時間が経過するまで処理が実行されます
        _interpreter.RunUntilDone();
    }
}

実運用の事を考えてない都合上かなり端折った部分も多いですが、比較的容易に機能を拡張していけるという事が少しだけでも伝われば幸いです。

さいごに

ここまで見た方はお気づきかなと思いますが、今回MiniScriptが元々持ってる構文やコマンドの解説だったり実運用上の話に関してはまったく解説していません。色々訳わからなかったらごめんなさい。

公式サイトのドキュメントがそれなりに充実しているので、MiniScriptについて知りたいことがあればそちらを参照してみてください。

シンプルで扱いやすい印象ではあるものの日本語情報が超少ないので、
もっと増えてくれると嬉しいな~~~~~~と思っています。

みんなもさわってみよう、MiniScript。

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

RSA暗号でRPAに使うパスワードを管理するRTA実況風解説

はーい、よーいスタート(小声)

ご猥拶

めんどくさいことしたくないからRPA始めたのにパスワード管理でめんどくさいことし始めているRTA、はーじまーるよー。
今回走っていくのはRSAで暗号化したパスワードをRPAで利用するためのシステム作りです。

みんな大好きWinActorは国産RPAツールとして導入率ナンバーワンとして人気を博しています。
しかし、WinActorはRPA(Robotic Process Automation)というよりはRDA(Robotic Desktop Automation)としての趣が強く、各個人のタスクを自動化するには十分ですが、導入から時間が立ち、全社展開など大規模化してきたときに管理能力の無さが目立ちます
そこで今回はWinActorの控えめに言ってウンチーコングちょっと足りない管理能力を補うため、パスワードの登録・更新・取得を行うためのシステムを自作したいと思います。

もっとも、WinActor以外の主要5大RPAソフトウェアはパスワードの保管方法を標準で持ってるんですけどね、初見さん。
参考:【RPA】ログイン情報の保管方法/ツール別比較

前置き

WinActorには変数にマスクをかけて外部から見えないようにする機能があり、これで見えてほしくない値を管理してくださいと言ってます。
これ幸いと、ログイン処理を必要とする業務を自動化するように依頼されたときに

  • パスワードを意味する変数を作成、初期値を設定してマスクをかける
    • 外部から見えないし、変数一覧を出力しても内容は出てこない
  • パスワード更新時は初期値を再度設定する
    • WinActor変数の初期値に直接入力するため、WinActorの編集権限を持つ人にパスワードを教える
  • ログイン失敗時は例外処理でユーザーあてにパスワードを更新するようメール送信する

これでパスワード管理、ヨシ! とされた方、1年後に「どうしてあの時ヨシ!って言ったんですか?」となるかもしれません。
各社の管理形態にも依りますが、弊社ではこのようなことが起こりました。

  • パスワードが期限切れになり、ログイン処理が失敗する
  • ユーザーが更新期限間近になったパスワードを更新するが、RPAで利用しているパスワードが更新されないため失敗する
  • ログイン失敗したら通知する仕組みにしていたが、そもそもユーザー側がパスワード管理に気をつけないと必ず失敗する仕組みにダメ出しされる
  • 複数のRPAシナリオでそれぞれパスワード変数を持っているので、同一ユーザーのパスワードを複数のシナリオで使っていたときに修正漏れが発生する
  • パスワードを変更するときに現場から口頭またはメールで教えてもらうため、管理者が不当にパスワードを知ってしまうことが問題視された

だから以下の要件をすべて満たすパスワード管理ソフトを用意する必要があったんですね。

  • パスワードをユーザーが管理者の手を借りず外部から変えられるようにする
  • 平文で保存せず、暗号化した状態で保存する
  • ユーザーに見えない場所からRPAツールが復号したパスワードを取得できる
  • 更新時期が近づいたらパスワードを更新するようユーザーに促す

開発環境

OpenSSL

RSA暗号鍵を作成するアプリ?です。
コマンドラインでopensslと入力し、以下のメッセージが表示される場合はWebからインストーラをダウンロードしましょう。
Win32/Win64 OpenSSL

openssl

'openssl' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。

Visual Studio

RSA暗号鍵を利用してパスワードの暗号化・復号を行うアプリケーションを作成します。
インストール方法や使い方は腐るほどページがあります。ググりましょう。

DB(Microsoft SQL Server)

管理できるならAccessでもExcelでも、最悪csvでも良いです。
ストアドプロシージャや制約を使える利便性を考えればRDBを使うのが一番だと思います。

レギュレーション

古の作法に則り、暗号化・復号を行うアプリ君と、更新時期警告アプリ君に名前をつけた時点から始めます。
入力速度を考慮してhomo.exe,les.exe,yajuu.exeでもいいですが、そんなことしたら保守する人に怒られちゃうだろ!となるのでわかりやすい名前にしましょう。

  • RPA_Password_Uploader.exe
    • ユーザーが自身の社員番号、システム名、平文のパスワードを入力する
    • アプリケーションがパスワードを暗号化し、DBにアップロードする
    • ユーザーが利用するため、公開領域にフォームアプリケーションで作りましょう。
  • RPA_Password_Getter.exe
    • 社員番号、システム名を引数にDBから暗号文を取得し、復号したパスワードを取得する
    • フォームである必要はありません。コンソールアプリケーションで作りましょう。
    • ユーザーから見えず、RPAツールのみアクセスできる領域に置きましょう。
  • RPA_Password_Alerter.exe
    • DBから更新時期が近づいたパスワードを取得し、ユーザーにメールで警告する
    • タスクスケジューラで毎日実行させるのでコンソールアプリケーション1択です。

タイマーストップはアプリケーションのコーディングが終了し、以下の要件をすべて満たすシステムが完成したところです。

  • パスワードをユーザーが管理者の手を借りず外部から変えられるようにする
  • 平文で保存せず、暗号化した状態で保存する
  • ユーザーに見えない場所からRPAツールが復号したパスワードを取得できる
  • 更新時期が近づいたらパスワードを更新するようユーザーに促す

計測開始

それでは計測開始です。

秘密鍵、公開鍵の作成

先駆者様が詳しく説明して頂いてます。是非LGTMしましょう。
C#でRSA暗号を使って署名や暗号化する

コマンドラインに以下を打ち込み、2048bitの秘密鍵を作り、それを参照して公開鍵を作ります。

$ openssl genrsa -out private-key 2048
$ openssl rsa -in private-key -pubout -out public-key

これによりprivate-key.pempublic-key.pemができました。
.pemは見慣れない拡張子だと思いますが、これをメモ帳で開くと以下の形のテキストファイルになっています。

-----BEGIN RSA PRIVATE(PUBLIC) KEY-----
(Base64エンコード)
-----END RSA PRIVATE KEY-----

このBase64エンコードされた文字列が暗号鍵になっており、これをDER(バイナリー形式)に変換して利用します。
先駆者様はC#でPEMファイルをBase64デコードして、DER(バイナリー形式)にするチャートを採用していますが、私は初めからPEMをDERに変換した状態でコーディングするチャートにしました。

試走ではPEMファイルを使うチャートでしたが、PEMをそのままの内容で読み込むとエラーとなります(環境によるかもしれませんが)。
詳しくはこちらをご覧ください。私はこれで4時間溶かすガバをしました。
OpenSSLで作成したRSA暗号鍵をC#で読み込む方法 4. 実行結果

上記のようにPEMファイルのまま利用するにはbyte変換コードを加える必要があるのですが、今回のレギュレーションはPEM縛りではないのでDERに遠慮なく変換します。

$ openssl rsa -in private-key.pem -out private-key.der -outform der
$ openssl rsa -pubin -in public-key.pem -out public-key.der -outform der

Uploader, Getterの暗号化・復号部分の作成

テスト用プロジェクトを作成し、秘密鍵private-key.derと公開鍵public-key.derをリソースファイルにぶち込んで、暗号化と復号が問題なく行えることを確認しましょう。
リソースファイルは名前をそれぞれprivate_key,public_keyに設定し、FileTypeにBinaryを指定します。
以下のコードを打ち込みます。

Crypto_Test.cs
using System;
using System.IO;
using System.Text;
using System.Security.Cryptography;

namespace Crypto_Test
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.Write("契約書だよ。ここにパスワードを書きな。 > ");
            string PlaneText = Console.ReadLine();
            Console.WriteLine($"PlaneText: {PlaneText}");

            byte[] PlaneByte = Encoding.UTF8.GetBytes(PlaneText);
            byte[] CipherByte = Encrypt(PlaneByte);
            string CipherText = Convert.ToBase64String(CipherByte);

            Console.WriteLine($"CipherText: {CipherText}");

            byte[] EncodingByte = Convert.FromBase64String(CipherText);
            byte[] DecryptByte = Decrypt(EncodingByte);
            string DecryptText = Encoding.UTF8.GetString(DecryptByte);

            Console.WriteLine($"DecryptText:{DecryptText}");
            Console.ReadLine();
        }

        /// <summary>復号</summary>
        /// <param name="encrypt">暗号化されたデータ</param>
        public static byte[] Decrypt(byte[] encrypt)
        {
            var provider = new RSACryptoServiceProvider();
            provider.ImportParameters(CreateParameter(Properties.Resources.private_key));
            return provider.Decrypt(encrypt, false);
        }

        /// <summary>暗号化</summary>
        /// <param name="data">暗号元データ</param>
        public static byte[] Encrypt(byte[] data)
        {
            var provider = new RSACryptoServiceProvider();
            provider.ImportParameters(CreatePublicParameter(Properties.Resources.public_key));
            return provider.Encrypt(data, false);
        }
        private static RSAParameters CreateParameter(byte[] der)
        {
            byte[] sequence = null;
            using (var reader = new BinaryReader(new MemoryStream(der)))
            {
                sequence = Read(reader);
            }

            var parameters = new RSAParameters();
            using (var reader = new BinaryReader(new MemoryStream(sequence)))
            {
                Read(reader); // version
                parameters.Modulus = Read(reader);
                parameters.Exponent = Read(reader);
                parameters.D = Read(reader);
                parameters.P = Read(reader);
                parameters.Q = Read(reader);
                parameters.DP = Read(reader);
                parameters.DQ = Read(reader);
                parameters.InverseQ = Read(reader);
            }
            return parameters;
        }

        private static RSAParameters CreatePublicParameter(byte[] der)
        {
            byte[] sequence1 = null;
            using (var reader = new BinaryReader(new MemoryStream(der)))
            {
                sequence1 = Read(reader);
            }

            byte[] sequence2 = null;
            using (var reader = new BinaryReader(new MemoryStream(sequence1)))
            {
                Read(reader); // sequence
                sequence2 = Read(reader); // bit string
            }

            byte[] sequence3 = null;
            using (var reader = new BinaryReader(new MemoryStream(sequence2)))
            {
                sequence3 = Read(reader); // sequence
            }

            var parameters = new RSAParameters();
            using (var reader = new BinaryReader(new MemoryStream(sequence3)))
            {
                parameters.Modulus = Read(reader); // モジュラス
                parameters.Exponent = Read(reader); // 公開指数
            }

            return parameters;
        }

        private static byte[] Read(BinaryReader reader)
        {
            // tag
            reader.ReadByte();

            // length
            int length = 0;
            byte b = reader.ReadByte();
            if ((b & 0x80) == 0x80) // length が128 octet以上
            {
                int n = b & 0x7F;
                byte[] buf = new byte[] { 0x00, 0x00, 0x00, 0x00 };
                for (var i = n - 1; i >= 0; --i)
                    buf[i] = reader.ReadByte();
                length = BitConverter.ToInt32(buf, 0);
            }
            else // length が 127 octet以下
            {
                length = b;
            }

            // value
            if (length == 0)
                return new byte[0];
            byte first = reader.ReadByte();
            if (first == 0x00) length -= 1; // 最上位byteが0x00の場合は、除いておく
            else reader.BaseStream.Seek(-1, SeekOrigin.Current); // 1byte 読んじゃったので、streamの位置を戻しておく
            return reader.ReadBytes(length);
        }
    }
}

契約書だよ。ここにパスワードを書きな。 > ii4koi4
PlaneText: ii4koi4
CipherText: dgYcFYeyKRz......
DecryptText:ii4koi4

これでPlaneTextがCipherTextを経由してDecryptTextになっていることを確認できました。
あとは暗号化部分のコードとpublic-keyをRPA_Password_Uploaderに、復号コードとprivate-keyをRPA_Password_Decrypterに移植すれば工事完了です...

安定を取ってテスト用プロジェクトを挟みましたが、再送するならここがタイム短縮ポイントになると思います。

SQL ServerのDB構築

SQL Serverの構築方法も公式ドキュメントが充実しています。読みましょう。

データベースを作る

RPA管理者と現場の担当者の両方がアクセスできるサーバーにデータベース[rpa_db]を作ります。
ローカルで作ってテストしてから移すのが安パイです。

テーブルを作る

以下のマスタを作ります。

  • m_system(RPAで利用するシステム一覧)
    • id
    • name
    • interval_month(パスワード更新間隔)
  • m_person(人員一覧)
    • id
    • name
    • smtp_address
  • t_password(登録されたパスワード一覧)
    • id
    • person_id
    • system_id
    • cipher_text
    • updated_at(更新日時)

今回のパスワード管理で最低限必要なテーブルです。
これらをJOINしたビュー(v_password)を作っておくと後々の開発に役立ちます。
あとは任意で以下の設定をするとより強固になります。

  • created_at(作成日時), updated_at(更新日時)の追加
    • いつから使われているか
  • PK(Primary Key), UK(Unique Key)の設定
    • idにPKを設定する
    • m_systemに同名のシステムが登録されないようにする
    • t_passwordでperson_idとsystem_idの組み合わせが被らないようにする
  • FK(Foreign Key)の設定
    • 人員一覧にないperson_idやシステム一覧にないsystem_idが登録されないようにする

新しいデータベースダイアグラムから作ると関係がわかりやすいです。

ストアドプロシージャを作る

@person_id, @system_id, @cipher_textを引数に、t_passwordにパスワードを新規登録、または更新するストアドプロシージャを作ります。
このストアドプロシージャの振る舞いは、

  • t_passwordに@person_id@system_idの組み合わせが
    • 存在しない場合 -> INSERT
    • 存在する場合 -> UPDATE

であることが求められるため、MERGE構文を使いましょう。

s_update_password
CREATE PROCEDURE [dbo].[s_update_password]
    @person_id INT,
    @system_id INT,
    @password VARCHAR(MAX)
AS
BEGIN

MERGE 
INTO dbo.t_password DA 
    USING ( 
        SELECT
        @person_id person_id
            , @system_id system_id
            , @password cipher_text
    ) w_param 
        ON ( 
            DA.person_id = w_param.person_id
        AND DA.system_id = w_param.system_id
        ) WHEN MATCHED THEN UPDATE 
SET
    cipher_text = w_param.cipher_text
    , updated_at = GETDATE() WHEN NOT MATCHED THEN 
INSERT ( 
    person_id
    , system_id
    , cipher_text
    , created_at
    , updated_at
) 
VALUES ( 
    w_param.person_id
    , w_param.system_id
    , w_param.cipher_text
    , GETDATE()
    , GETDATE()
)
;

END

Uploader, GetterのSQL Server対応

ここまでくれば後は消化試合です。
Uploaderは社員番号、システム名、パスワードを入力してもらい、パスワードを暗号化してs_update_passwordを起動するフォームアプリに、
Getterは社員番号、システム名を引数にパスワードを復号するコンソールアプリにしましょう。

実際のコードは自分で打って確かめてくれよな!!

自分で作ったはいいものの、主要な部分だけを抜き出すのが難しいコードになってしまい、コードを載せることができませんでした。ユルシテ・・・

Alerter.exeの作成

c#でメールを送信する場合、.NETのSmtpClientを利用する記事は多いですが、SmtpClientは廃棄予定らしいです。
NuGetでMailKitをインストールし、MailKitからメール配信を行います。
今回は本文の装飾にもこだわり、HTMLで文字の強調やリンクを付けるようにしました。

MailManager.cs
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using System.IO;

namespace RPA_Password_Alerter
{
    class MailManager
    {
        private string From { get; set; } = "RPA担当者@hoge.com";
        public string[] To { get; set; } = new string[] { "RPA担当者@hoge.com" }; // Main内で指定するが、nullにならないよう初期化しておく
        public string[] Cc { get; set; }
        public string Subject { get; set; } = "自動送信";
        public HtmlBuilder HtmlBody { get; set; } = new HtmlBuilder();
        public string[] Attatchments { get; set; } = new string[] { };
        private string Host { get; set; } = "hogehoge.net";
        private int Port { get; set; } = 25;

        static MailManager()
        {
            ServicePointManager.ServerCertificateValidationCallback += (sender, cert, chain, sslPolicyErrors) => true;
        }

        public async Task SendEmailAsync()
        {
            MimeMessage mime = new MimeMessage();
            mime.From.Add(new MailboxAddress("", From));

            this.To.ToList().ForEach(v => mime.To.Add(new MailboxAddress("", v)));
            this.Cc?.ToList().ForEach(v => mime.Cc.Add(new MailboxAddress("", v)));

            mime.Subject = this.Subject;

            Multipart multipart = new Multipart("mixed")
            {
                new TextPart(MimeKit.Text.TextFormat.Html) { Text = this.HtmlBody.HtmlString }
            };
            foreach (var item in Attatchments)
            {
                multipart.Add(new MimePart()
                {
                    Content = new MimeContent(File.OpenRead(item)),
                    ContentDisposition = new ContentDisposition(),
                    ContentTransferEncoding = ContentEncoding.Base64,
                    FileName = Path.GetFileName(item)
                });
            }
            mime.Body = multipart;

            using (var client = new SmtpClient())
            {
                await client.ConnectAsync(this.Host, this.Port, SecureSocketOptions.StartTls);
                await client.SendAsync(mime);
                await client.DisconnectAsync(true);
            }
        }

        public class HtmlBuilder
        {
            StringBuilder builder = new StringBuilder();
            public void AddStyle(string v) => builder.AppendLine("<style>" + v + "</style>");
            public void AddParagraph(string v) => builder.AppendLine("<p>" + v + "</p>");
            public void AddLink(string displayname, string path) => AddParagraph($"<a href=\"{path}\">{displayname}</a>");
            public string HtmlString => builder.ToString();

        }

    }
}
Program.cs
using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Linq;
using System.Threading.Tasks;

namespace RPA_Password_Alerter
{
    class Program
    {
        static void Main(string[] args)
        {
            MainAsync().Wait();
        }

        private static async Task MainAsync()
        {
            foreach (var item in SqlData.data.Where(d => d.ExpireDate.AddDays(-14) < DateTime.Now))
            {
                MailManager mail = new MailManager()
                {
                    To = new string[] { item.PrimarySmtpAddress },
                    Cc = new string[] { "RPA担当者@hoge.com" },
                    Subject = $"[{item.SystemName}]のパスワードを更新してください"
                };
                mail.HtmlBody.AddParagraph($"{item.PersonName} さん");
                mail.HtmlBody.AddParagraph($"RPAで利用している[{item.SystemName}]のパスワードの有効期限が残り<strong>{(item.ExpireDate - DateTime.Now).Days}日</strong>です。");
                mail.HtmlBody.AddParagraph($"<strong>{item.ExpireDate.ToShortDateString()}までに</strong>下記リンクからパスワードの更新を実施してください。");
                mail.HtmlBody.AddLink("パスワード更新フォーム", Properties.Resources.Password_Updater); // UploaderのPath
                mail.HtmlBody.AddParagraph($"※ 本メールは前回のパスワード更新({item.UpdateDate})から{item.IntervalMonth}ヵ月後に訪れるパスワード有効期限の2週間前から発信されます。");
                mail.HtmlBody.AddParagraph($"※ パスワード更新後、メールは配信されなくなります。");

                await mail.SendEmailAsync();
            }
        }
    }

    static class SqlData
    {
        public static List<Schema> data = new List<Schema>();
        private static DataTable dataTable { get; set; } = new DataTable();
        private static SqlConnectionStringBuilder stringBuilder => new SqlConnectionStringBuilder() { DataSource = "localhost", InitialCatalog = "RPA_DB", UserID = "sa", Password = "hogefuga" };
        static SqlData()
        {
            using (SqlConnection connection = new SqlConnection(stringBuilder.ConnectionString))
            {
                connection.Open();
                using(SqlCommand command = new SqlCommand("SELECT [system_name], [person_name], [updated_at], [interval_month], [alert_date], [smtp_address] FROM [v_password]", connection))
                {
                    using (SqlDataAdapter adapter = new SqlDataAdapter(command))
                    {
                        adapter.Fill(dataTable);
                    }
                }
            }
            dataTable.AsEnumerable().ToList().ForEach(d => data.Add(new Schema(d)));
        }

        public class Schema
        {
            public string SystemName { get; set; }
            public string PersonName { get; set; }
            public string PrimarySmtpAddress { get; set; }
            public readonly DateTime UpdateDate;
            public readonly int IntervalMonth;
            public readonly DateTime ExpireDate;

            public Schema(DataRow dataRow)
            {
                try
                {
                    SystemName = dataRow["system_name"].ToString();
                    PersonName = dataRow["person_name"].ToString();
                    DateTime.TryParse(dataRow["updated_at"].ToString(), out UpdateDate);
                    int.TryParse(dataRow["interval_month"].ToString(), out IntervalMonth);
                    DateTime.TryParse(dataRow["alert_date"].ToString(), out ExpireDate);
                    PrimarySmtpAddress = dataRow["smtp_address"].ToString();
                }
                catch { }
            }
        }
    }

}

メールサーバーを持たない会社の場合、Outlookを起動して送信するプログラム等にしましょう。
もしTeamsやSlackでBotを作れるなら、Botに発言させるのもいいと思います。

終わり

ここでタイマーストップ!
記録は・・・30時間弱ですかね(体感)

さて、完走した感想ですが(激うまギャグ)、
自分で0から仕組みを考え、環境を作り、コーディングする作業はやりがいがあるものの、RPAでは味わえないコーディングの楽しみを覚えました。
しかし、C#の経験が薄いせいか、もう少し詰めたコーディングができたかと思ってます。
データベースが用意できるならDataSetを使えば簡潔にコードを書けたし、UploaderやGetterのコードも載せることができたかとおもいます。
あとは、仮にもRTA実況風解説を冠するなら、動画とは言わずとも写真とかうまく使えれば面白く作れたかと思います。技術記事は難しいですね。

今回の記事はここまでとします。ご視聴、ありがとうございました。
皆さんのRPA (Robotic Process Automation) Advent Calendar 2020記事、楽しみにしております。

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

【C#】usingの色々な使い方

最初に

どうも、ろっさむです。

今回は様々なusingの使い方を紹介する記事となります。
環境は以下の通りです。

  • Windows10
  • JetBrains Rider

usingの使い方

ざっと以下の4つの使い方があります。

  • usingディレクティブ
  • usingエイリアスディレクティブ
  • using静的ディレクティブ
  • usingステートメント

一つずつ見ていきましょう。

usingディレクティブ

これは馴染みのある人が多いと思います。

異なる名前空間に定義されているクラスを使用する場合には System.Collections.Generic.List<int> numbers;のように「名前空間.クラス名」と記述する必要があります。ここでusingディレクティブをファイルの頭で使用することで、以下のように名前空間の指定を省略して記述できるようになります。

using System.Collections.Generic;

class TestProgram
{
    static void Main(string[] args)
    {
        List<int> index;
    }
}

usingエイリアスディレクティブ

こちらはtypedefのように使えるusing エイリアス(別名) ディレクティブという機能です。
名前の通り、名前空間に別名を付けることができます。

using Ints = System.Collections.Generic.List<int>;

class TestProgram
{
    static void Main(string[] args)
    {
        var index = new Ints();
    }
}

using静的ディレクティブ

C#6.0から使える機能です。
usingディレクティブの機能と同じことが静的メソッドにも適用できるようになります。また、列挙型のメンバーも静的なため、この機能が適用できます。

using System;
using static System.Enum;
using static Colors;

enum Colors
{
    Red,
    Yellow,
    Blue,
    White,
    Black,
}

class TestProgram
{
    static void Main(string[] args)
    {
        // using static System.Enum がない場合は Enum.GetValues と書く必要がある。
        foreach (int color in GetValues(typeof(Colors)))
        {
            Console.WriteLine(color);
        }

        // using static Colors がない場合は Colors.Blue と書く必要がある。
        var myFavoriteColor = Blue;
    }
}

usingステートメント

普段インスタンス化されたオブジェクトはGCによって管理され、自動で破棄が行われます。ですがファイルなどのアンマネージド リソースを使用する場合はGCの管理対象外となり、以下の流れが必要となります。

  1. リソースを使用する権利を取得
  2. リソースに対する操作を行う
  3. リソースの使用権を破棄する

この、「リソースの使用権を破棄する」を怠ると、ファイルにロックがかかった状態のまま開きっぱなしになって他の処理からアクセスできなくなったり面倒なことが起こります。

なのでtry-catch-finallyを使用した上で、処理を行います。finallyは例外が発生しても必ず実行されるブロックなので、ここでリソースの使用権を破棄するためのClose()を呼び出します。

class TestProgram
{
    static void Main(string[] args)
    {
        // リソースを使用する権利取得:コンストラクタ(ファイルパス, ファイル操作のモード, 
        // ファイル操作でのアクセスレベル, ファイルを開いている間の他のプログラムからのアクセス許可レベル = Noneは不許可)
        FileStream fs = new FileStream("hoge.txt", FileMode.Open, FileAccess.Read, FileShare.None);
        try
        {
            // 何かしらの処理
        }
        catch (Exception e)
        {
            // 例外処理
        }
        finally
        {
            if(fs != null)
                fs.Close();
        }
    }
}

しかし、アンマネージド リソースを使用する場合に、毎回これを書くのは面倒くさいかと思います。

ここでusingステートメントという機能を用いることで簡潔に書くことができるようになります。

usingステートメントを用いることで、コンパイラ側で自動的にリソースの破棄用のILを生成します。

class TestProgram
{
    static void Main(string[] args)
    {
        using (FileStream fs = new FileStream("hoge.txt", FileMode.Open, FileAccess.Read, FileShare.None))
        {
            Console.WriteLine(fs.Length);
        }
    }
}

ただしこの記述は IDisposable を実装するクラスのみに使用できます。

更にC#8.0からは以下のように、変数宣言時にusingをつけることで更なる簡略化ができるようになりました。

class TestProgram
{
    static void Main(string[] args)
    {
        using var fs = new FileStream("hoge.txt", FileMode.Open, FileAccess.Read, FileShare.None);
        Console.WriteLine(fs.Length);   
    }
}

参考

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