20201206のC#に関する記事は10件です。

会議室の予約のステータスについて考える紆余曲折

本記事は、 DDD-Community-Jp Advent Calendar 2020の6日目です。

はじめに

DDD-Community-JP(以下、DDDCJ)内では架空の会議室予約のドメインを考え、C#で実装する勉強会を行っています。

この勉強会の中で会議室の予約に対する考え方で、いろいろ試行錯誤をしていき、
いまのところ、こんな感じで落ち着いてきたなと思ったので、紆余曲折を含めて書いていこうと思います。

モデリング全体の紆余曲折については、以下の記事をご参照ください。

なお、この勉強会では日本語でプログラミングをしています。
日本語プログラミングの良さ等については、下記の記事をご参照ください。

予約をするときに、ステータスがあると考えた

始めに会議室の予約をする、と考えた時に、

予約無し → 予約済み → キャンセル済み もしくは 利用中

みたいな感じでステータスがあると考えました。

予約をする前の概念 → 予約希望?

先程のステータスの中で、以下の予約無しと予約済みの間にある概念ってなんだろう、ということが気になりました。

予約無し → (この間は???) → 予約済み 

予約をしようとした時に、その場所が埋まっているかどうか、予約できる時間帯なのかどうか、といった予約に関するルールがあると思います。

そして、予約が成立したということは、それは保存がされたとき。

当時はそう捉えていたので、予約に関するオブジェクトを組み立て、それを保存するまでの過程を何と表現すればよいのか。

それを、私たちは予約希望と捉えていました。

public class 予約希望
{
    private readonly MeetingRoom room;
    private readonly ReserverId reserverId;
    private readonly 予約期間 range;
    private readonly 想定使用人数 想定使用人数;

    public 予約希望(MeetingRoom room, ReserverId reserverId, 予約期間 range, 想定使用人数 想定使用人数)
    {
        this.room = room;
        this.range = range;
        this.想定使用人数 = 想定使用人数;
        this.reserverId = reserverId;
    }

    public MeetingRoom Room => room;
    public 予約期間 Range => range;
    public 予約年月日 予約年月日 => Range.予約年月日;

    public ReserverId ReserverId => reserverId;
    public 想定使用人数 想定使用人数_ => this.想定使用人数;

}

ユースケース層のメソッド

public bool 予約する(予約希望 予約希望) {

    予約済み群 予約希望日の予約の一覧 = repository.この日の予約一覧をください(予約希望.予約年月日);

    if (予約希望日の予約の一覧.かぶってますか(予約希望)) {
        return false;
    }

    repository.Save(予約希望);
    return true;
}

ここまで見るとわかりますが、予約希望はユースケースのメソッドに対するコマンドオブジェクトに近い振る舞いになっています。

予約希望自体に、予約に関するルールが記述できないのも問題です。
予約希望を組み立てるのが、実質このメソッドを呼ぶプレゼンテーション層になってしまうので、ルールを書いておくと、ドメイン知識の流出に繋がります。

予約をする前の概念は必要か?

予約希望、といった名前が悪いのかと思い、
仮予約とか、成立前予約とか、いろいろ考えておりましたが、

「成立しなかったならば、登録されずに捨てられるというあたりも勘案しておきましょう。
申請を受けてから登録されるまでに時間がかかるので一時的に保存しておくようであれば、区別する必要があるけれど、今回は自動的に登録まで一気に実行するだろうから区別する必要がどこまであるのか」

といった指摘を受けまして、今回考えたシステムの中で、申請前の概念を区別して表現するといった意味は無いだろうということで、そこを表現することは止めることにしました。1

public class 予約
{
    private 予約Id 予約Id;

    private 予約者Id よやくしゃ;
    private 利用期間.利用期間 りようきかん;
    private 会議室Id かいぎしつ;
    private 会議参加予定者 かいぎさんかよていしゃ;

    public 予約(予約者Id よやくしゃ, 利用期間.利用期間 りようきかん, 会議室Id かいぎしつ, 会議参加予定者 かいぎさんかよていしゃ)
    {
        if (!new 予約可能ルール.予約可能ルール().IsSatisfied(りようきかん))
        {
            throw new ルール違反Exception();
        }

        // 予約可能かどうか判定する?
        予約Id = 予約Id.Create();
        this.よやくしゃ = よやくしゃ;
        this.りようきかん = りようきかん;
        this.かいぎしつ = かいぎしつ;
        this.かいぎさんかよていしゃ = かいぎさんかよていしゃ;
    }

    /// <summary>
    /// 変更用のコンストラクタ
    /// </summary>
    public 予約(予約Id よやくid, 予約者Id よやくしゃ, 利用期間.利用期間 りようきかん, 会議室Id かいぎしつ, 会議参加予定者 かいぎさんかよていしゃ)
    {
        this.予約Id = よやくid;
        this.よやくしゃ = よやくしゃ;
        this.りようきかん = りようきかん;
        this.かいぎしつ = かいぎしつ;
        this.かいぎさんかよていしゃ = かいぎさんかよていしゃ;
    }

    public 予約 変更する(予約Id 予約Id, 予約者Id 予約者Id, 利用期間.利用期間 利用期間, 会議室Id 会議室Id, 会議参加予定者 会議参加予定者)
    {
        if (!new 予約変更可能ルール.予約変更可能ルール().IsSatisfied(りようきかん))
        {
            throw new ルール違反Exception();
        }

        return new 予約(予約Id, 予約者Id, 利用期間, 会議室Id, 会議参加予定者);
    }
}

ユースケースの引数は、コマンドオブジェクトとして用意するようにしました。
引数で貰ったオブジェクトの値から、予約を組み立てて問題なく生成ができれば保存をするといった形になっています。

public async Task 会議室予約するAsync(予約Request request)
{
    try
    {
        var よやく = new 予約(request.よやくしゃ,
            request.りようきかん,
            request.かいぎしつ,
            request.かいぎさんかよていしゃ);

        await _repository.Add(よやく);
    }
    catch (ルール違反Exception ex)
    {  
        throw new UseCaseException(ex);
    }

}

分け方に関して

実装をモブでやっている中で、ステータスをどう表現するかを考えたときに、以下の指摘が一つわかりやすい指針になるなと感じています。

  • 振る舞いが変わるならば、予約前後で別クラスというのはあり
  • 振る舞いが変わらないならば、同じクラスで状態として扱うというのもあり
    • いわゆる「区分」みたいなやつで区別するというイメージ

キャンセルされた予約に関心があるのは、予約する側では無い?

キャンセル済みの予約をどう表現するのか。
明らかに予約済みとキャンセル済みの予約は関心事が違うので、しっかりと分けておきたいです。

では予約の中で予約ステータスを保持しておき、「キャンセルする」のユースケースが実行されたときにキャンセル済みに変更するか?
それに関しても、予約として成立したものを「予約」と表現しているのに、その中にキャンセルされた、というのはおかしいのではないか、という違和感が残ります。

あれこれ悩ませていたときに、

「キャンセルされた枠があって、そこを予約する人は、そこが元々空いてたのか、キャンセルされてあいたのかは、気にしないですよね。
元々空いてたのかキャンセルされたのかが気になるのは、
利用実績とか利用履歴を追跡したい人の観点じゃないかしら」

という指摘に、たしかに! となりました。

キャンセルした時に、そのとき会議室を参加する予定の人たちには、通知をする必要はあれど、
予約をする観点から見た時に、キャンセル済み予約として概念を表現する必要性は無いと気づきました。

そのことから、予約の集約は、予約が成立した状態=予約済み を表現するものであり、予約成立前とキャンセル済みのものは、その範囲に入らないことが決まりました。

予約をする、予約の実績を参照する のコンテキスト分け

EventStormingでも分析していましたが、
「予約をする」と「会議室の利用」と「予約と利用の実績確認」は、今の所コンテキストを分けています。

それは、「予約をする」は利用前の時系列であり、
「会議室の利用」は当日会議室がどう利用されたか(予約しなくても使えるパターンもある)、
「予約と利用の実績確認」は、利用者側ではなく、会議室の管理者というアクターになってくる為、関心事が違うだろうとコンテキストを分けています。

こういったコンテキストを分けておくと、いま考えている関心事は、誰の関心事なのだろうかと考えることに繋がっていけそうだと感じました。

キャンセルという振る舞いは、予約自身が持つものか?

予約をキャンセルするという振る舞いについては、予約の集約が持つものだろうか? と考えた時に、「自分自身がキャンセルする」という行為に、またぎこちなさを感じました。
実際、予約をキャンセルする行為は、永続化されているデータを削除する行為です。
既存の予約が存在するかどうか、キャンセルしても良い予約なのか(まだ予約日当日を迎えていないなど)を判断するルールはあれど、それを予約集約の中でやるのは、収まりが悪い感じがしています。

「『キャンセルする』が実は、『特定のオブジェクトに持たせるとぎこちなくなる』というアレなのかもしれない」

上記のような指摘を受けて、これはおそらく重要な知識をもった手続きではあれど、ひとつの集約に置くのぎこちない感じがするので、ドメインサービスとして定義したほうが良いだろうと考えました。

まとめ

予約のステータスを考えたときに、以下のような気付きがありました。

  • ドメインオブジェクトとして考えていたのに、その中に振る舞いやルールが無い状態 → ドメイン貧血症になっていたら、どこか別にルールが存在してないか、もしくはその概念が本当に必要なのかを考える

  • あるオブジェクトのステータスを考えたときに、いまのコンテキストでは、このステータスに関心があるのだろうか? と一旦考える

  • キャンセルの概念はかなりのぎこちなさを感じるので、重要な分析ポイント

  • 振る舞いが変わるならば、予約前後で型を別に表現するのはあり。振る舞いが変わらないのであれば、ステータスを区分として持っておく

  • コンテキストの分ける候補として、時系列(事前に予約する、当日に予約したものを利用する)やアクターの違いに着目してみる

特に、振る舞いの変わる変わらないで、型で区別するか、ステータス区分を集約の中で保持しておくか、というのは自分ではモヤモヤしていたので、良い指針だなと感じました。


  1. もちろん、予約してから成立までの時間が掛かる(貸し出す側の確認とか、何かしら審査が必要とか)としたら、そこを表現するのに価値はあると思います 

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

【自分用】連続画面キャプチャした画像を一括トリミングするツールをつくってみた

まえがき

【自分用】Microsoft Teamsのチャットログを無理やり保存する (自動スクロールしながら画面キャプチャするツールつくってみた) - Qiita
の補助用ツール

超てきとうコーディングなのであしからず。

スクリーンショット

image.png

Trim!ボタンでトリミング結果を保存します。

ソースコード

ImagesTrimmer.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

class MainForm : Form
{
    class NativeMethods
    {
        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool SetProcessDPIAware();
    }
    PictureBox pct;
    ListView lsvFiles;
    NumericUpDown nudScale;
    NumericUpDown nudBoundLeft;
    NumericUpDown nudBoundTop;
    NumericUpDown nudBoundRight;
    NumericUpDown nudBoundBottom;

    Image _imgPreviewCache;
    Rectangle _imgPreviewRect;
    bool _disableNudEvent;

    MainForm()
    {
        NativeMethods.SetProcessDPIAware();

        //Text = ;
        ClientSize = new Size(1100, 700);

        nudScale = new NumericUpDown(){
            Location = new Point(0, 0),
            Width = 100,
            Maximum = 100,
            Value = 50,
            Minimum = 10
        };
        nudScale.ValueChanged += (s,e)=>{ShowScaledPreviewImage();};
        Controls.Add(nudScale);


        Button btn = new Button(){
            Location = new Point(170, 0),
            Size = new Size(100, 30),
            Text = "Trim!",
        };
        btn.Click += (s,e)=>{TrimAndSaveImages();};
        Controls.Add(btn);


        Controls.Add(nudBoundLeft = new NumericUpDown(){
            Location = new Point(0, 25), Width = 60,
            Maximum = 50000,   Value = 0,   Minimum = 0
        });
        Controls.Add(nudBoundTop = new NumericUpDown(){
            Location = new Point(70, 25), Width = 60,
            Maximum = 50000,   Value = 0,   Minimum = 0
        });
        Controls.Add(nudBoundRight = new NumericUpDown(){
            Location = new Point(140, 25), Width = 60,
            Maximum = 50000,   Value = 1000,   Minimum = 0
        });
        Controls.Add(nudBoundBottom = new NumericUpDown(){
            Location = new Point(210, 25), Width = 60,
            Maximum = 50000,   Value = 700,   Minimum = 0
        });
        nudBoundLeft.ValueChanged   += (s,e)=>{if(!_disableNudEvent){RedrawBound();}};
        nudBoundTop.ValueChanged    += (s,e)=>{if(!_disableNudEvent){RedrawBound();}};
        nudBoundRight.ValueChanged  += (s,e)=>{if(!_disableNudEvent){RedrawBound();}};
        nudBoundBottom.ValueChanged += (s,e)=>{if(!_disableNudEvent){RedrawBound();}};


        lsvFiles = new ListView(){
            Location = new Point(0, 50),
            Size = new Size(300, 650),
            View = View.Details,
            FullRowSelect = true,
            GridLines = true,
            HideSelection = false,
        };
        lsvFiles.Columns.Add("Name", 250);
        //lsvFiles.Columns.Add("W x H", 100);
        lsvFiles.SelectedIndexChanged += (s,e)=>{LsvFiles_SelectedIndexChanged();};
        Controls.Add(lsvFiles);


        pct = new PictureBox(){
            Location = new Point(300, 0),
            Size = new Size(800, 700),
            Image = new Bitmap(800, 700),
        };
        pct.MouseDown += Pct_MouseDown;
        Controls.Add(pct);

        Load += (s,e)=>{LoadImageList();};
    }

    void LoadImageList()
    {
        lsvFiles.Items.Clear();

        string[] filenames = Directory.GetFiles(@"img/", "TeamsCapture*.png", SearchOption.TopDirectoryOnly);

        Array.Sort<string>(filenames, delegate(string a, string b)
        {
            return String.Compare(a, b, true); // ignore case
        });

        lsvFiles.BeginUpdate();
        try {
            foreach ( string filename in filenames ) {
                lsvFiles.Items.Add(new ListViewItem(new string[]{filename}));
            }
        }
        finally {
            lsvFiles.EndUpdate();
        }
    }

    void LsvFiles_SelectedIndexChanged()
    {
        var tmp = lsvFiles.SelectedIndices;
        if (tmp.Count != 1) {
            return;
        }

        if(_imgPreviewCache!=null){
            _imgPreviewCache.Dispose();
        }
        _imgPreviewCache = Image.FromFile(lsvFiles.Items[tmp[0]].Text);

        ShowScaledPreviewImage();
    }


    void ShowScaledPreviewImage()
    {
        float zoomRatio = ((float)nudScale.Value)/100;

        Graphics g = Graphics.FromImage(pct.Image);
        g.FillRectangle(Brushes.White,0,0,pct.Image.Width,pct.Image.Height);

        if ( _imgPreviewCache != null ) {

            _imgPreviewRect.Width  = (int)Math.Round(_imgPreviewCache.Width * zoomRatio);
            _imgPreviewRect.Height = (int)Math.Round(_imgPreviewCache.Height * zoomRatio);
            g.DrawImage(_imgPreviewCache, _imgPreviewRect);
        }
        int left   = (int)nudBoundLeft.Value;
        int top    = (int)nudBoundTop.Value;
        int right  = (int)nudBoundRight.Value;
        int bottom = (int)nudBoundBottom.Value;
        if(left>right){int tmp=left;left=right;right=tmp;}
        if(top>bottom){int tmp=top;top=bottom;bottom=tmp;}
        Pen pen = new Pen(Color.Blue, 3.0f);
        g.DrawRectangle(pen, left*zoomRatio, top*zoomRatio, (right-left)*zoomRatio, (bottom-top)*zoomRatio);
        g.Dispose();

        pct.Refresh();
    }

    void RedrawBound()
    {
        ShowScaledPreviewImage();
    }

    void Pct_MouseDown(object sender, MouseEventArgs e)
    {
        float zoomRatioInverse = 100/((float)nudScale.Value);

        if(e.Button == MouseButtons.Left) {
            _disableNudEvent = true;
            nudBoundLeft.Value = (int)(e.X*zoomRatioInverse);
            _disableNudEvent = false;
            nudBoundTop.Value  = (int)(e.Y*zoomRatioInverse);
        }
        else {
            _disableNudEvent = true;
            nudBoundRight.Value = (int)(e.X*zoomRatioInverse);
            _disableNudEvent = false;
            nudBoundBottom.Value  = (int)(e.Y*zoomRatioInverse);
        }
    }

    void TrimAndSaveImages()
    {
        Rectangle rect;
        {
            int left = (int)nudBoundLeft.Value;
            int top  = (int)nudBoundTop.Value;
            int right = (int)nudBoundRight.Value;
            int bottom = (int)nudBoundBottom.Value;
            if(left>right){int tmp=left;left=right;right=tmp;}
            if(top>bottom){int tmp=top;top=bottom;bottom=tmp;}
            rect = new Rectangle(left,top,right-left,bottom-top);
        }

        int cnt = 0;
        foreach(ListViewItem item in lsvFiles.Items) {
            string filename = item.Text;
            string destFileName = @"trimmed/"+cnt.ToString().PadLeft(6,'0')+".png";
            var img = Image.FromFile(filename);
            Bitmap bmp = new Bitmap(rect.Width, rect.Height);
            var g = Graphics.FromImage(bmp);
            //public void DrawImage (System.Drawing.Image image, int x, int y, System.Drawing.Rectangle srcRect, System.Drawing.GraphicsUnit srcUnit);
            g.DrawImage(img, 0, 0, rect, GraphicsUnit.Pixel);
            bmp.Save(destFileName, ImageFormat.Png);
            g.Dispose();
            img.Dispose();
            bmp.Dispose();
            cnt++;
        }
    }

    [STAThread]
    static void Main(string[] args)
    {
        Application.Run(new MainForm());
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

.Net 5 時代のテストフレームワーク比較

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

Microsoft 公式ドキュメント .NET でのテスト - .NET Core | Microsoft Docs に記載されている主要な三つの xUnit, NUnit, MSTest フレームワークを比較してみます。

結論、どれを使うべきか

新しいプロジェクトなら xUnit か NUnit を使うとよいと思います。過去のプロジェクトのマイグレーションならそのプロジェクトで使っているフレームワークでよいと思います。

Visual Studio からの実行や CLI (dotnet test) の実行、例外テストやデータドリブンテスト (Theory, DataSource) といった主要な機能はどのフレームワークでも対応しています。

個人的には xUnit の書き方が好きなので xUnit を使うことが多いです。

トレンド

xUnit と NUnit が人気で、 MSTest は低めの結果になっています。 2020/12/6 での計測です。

過去一年間の NuGet

xUnit, NUnit, MSTest の順です。 MSTest はだいぶ低いですね。

NuGet Trends

Image from Gyazo

GitHub

Stars を見ると xUnit が多いですね。 NUnit もそれなりです。 MSTest はとても少なくなっています。

Github Compare

Image from Gyazo

2020 年の検索ワード

日本で見ると xUnit が少し低め。 NUnit, MSTest が同率です。

Japan - xUnit, NUnit, MSTest - Explore - Google Trends

Image from Gyazo

世界中で見ると xUnit, NUnit が高いです。 MSTest は低いですね。

Worldwide - xUnit, NUnit, MSTest - Explore - Google Trends

Image from Gyazo

細かい話(もっと知りたい人向け)

xUnit, NUnit はどちらも .NET Foundation がオーナーで、 MSTest は Microsoft がオーナーです。なのでどれも Microsoft 製なようなものです。
実際に Microsoft 関連の GitHub Repository を見てみると、 dotnet/aspnetcoreAzure/azure-powershell は xUnit を使っており、 dotnet/maui は NUnit を使っています。
日本発の C# フレームワークとライブラリで有名な企業の Cysharp, Inc の比較的新しいリポジトリ Cysharp/ConsoleAppFramework は xUnit を使っています。
MSTest は GitHub を検索してみた mstest · GitHub Topics のですが、これと言ってよさそうなものが見つかりませんでした。

ライセンスは NUnit と MSTest は MIT License で、 xUnit のみ Apache License 2.0 (※ LICENCE ファイルの書き方が特殊だけどたぶん合っているはず) です。なのでどれもライセンス表記で使えます。商用などあまり気にしなくても大丈夫ですね。

それぞれの機能やコードの書き方の違いなどは Unit Testing Frameworks: XUnit vs. NUnit vs. MSTEST for .NET and .NET Core | by Robin Ding | MediumComparing xUnit.net to other frameworks > xUnit.net が参考になると思います。内容は英語ですが表形式なので理解できるかと思います。

.NET 5 とそれぞれのテストフレームワークで作ったソースコードは KtoZ/dotNet5TestFrameworkDifference に置いてあるので参考にしてください。

参考

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

【自分用】Microsoft Teamsのチャットログを無理やり保存する (自動スクロールしながら画面キャプチャするツールつくってみた)

まえがき

いろいろやってみたが、画面キャプチャしかやりようがなさそうだったので、やってみた。
(ライセンスによってはTeamsに保存機能があるっぽい??)

使用にあたって

本プログラムはマウス入力を自動で操作するので、非常にキケンなしろものです。
安易に使用せず、プログラムおよびソース等の使用は自己責任でお願いします。

画像のロード等を待たない(一定時間ごとにスクロールさせる)ので、データが正しく取り込めないケースがありえます。

マルチスクリーンでうまく動作するか未確認

スクリーンショット/使い方

image.png

■各コントロール
数値:SendInputへ指定するマウススクロールの量(単位不明。負数にすることもできます。)
Start Injectionボタン:ボタンを押すと、約3秒後にスクロールと画面キャプチャ&保存を開始します。
Stop(Abort)ボタン:ボタンを押すと処理を停止します。※これを押すまで止まりません。

■使い方手順
0-a. 注意事項と手順を読む。
0-b. ネットワークを遮断しておく or チャットやメールなどの割り込みが入らない時間帯で作業する。
0-c. すぐに停止できるように、停止させるまで目を離さないこと!
1. Teamsを立ち上げておき、キャプチャしたいログを開いておく。
2. 本プログラムを起動する。
3. 数値を適宜調整する(試しに使ってみてから変更する)。
4. Start Injectionボタンを押す。
5. Teamsのログがマウスのホイール操作でログがスクロールする状態にする(ログに操作フォーカスを移す)。
6. スクロールし終わるのを待つ。
7. Stop(Abort)ボタンを押す。
8. 本プログラムを終了する。
9. 出力されたファイルを確認する。
10. 適宜手順0からやり直し
11. 別途トリミングツールを使って必要な箇所だけ取り出す

ソースコード

CopyTeamsChatAsImg.cs

using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;


// KeyValueWithUpDown
public class KeyUD
{
    public enum Stroke{
        Down,
        Up
    };

    public Keys Key{get;private set;}
    public Stroke KeyStroke{get;private set;} // true: down,   false: up

    public KeyUD(Keys key, Stroke keyStroke) {
        Key = key;
        KeyStroke = keyStroke;
    }
}

class NativeUtil
{
    private static class NativeMethods
    {
        [DllImport("user32.dll", SetLastError = true)]
        public static extern void SendInput(int nInputs, Input[] pInputs, int cbsize);

        [DllImport("user32.dll", EntryPoint = "MapVirtualKeyA")]
        public static extern int MapVirtualKey(int wCode, int wMapType);

        //[DllImport("user32.dll", SetLastError = true)]
        //public extern static IntPtr GetMessageExtraInfo();


        [DllImport("user32.dll")]
        public static extern IntPtr GetDC(IntPtr hwnd);

        [DllImport("gdi32.dll")]
        public static extern int BitBlt(IntPtr hDestDC,
            int x,
            int y,
            int nWidth,
            int nHeight,
            IntPtr hSrcDC,
            int xSrc,
            int ySrc,
            int dwRop
        );

        [DllImport("user32.dll")]
        public static extern IntPtr ReleaseDC(IntPtr hwnd, IntPtr hdc);

        [DllImport("user32.dll")]
        public static extern IntPtr GetWindowDC(IntPtr hwnd);

        [DllImport("user32.dll")]
        public static extern IntPtr GetForegroundWindow();

        [DllImport("user32.dll")]
        public static extern int GetWindowRect(IntPtr hwnd, ref  RECT lpRect);


        [DllImport("user32.dll")]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool SetProcessDPIAware();
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct RECT 
    {
        public int left;
        public int top;
        public int right;
        public int bottom;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct POINT
    {
        public int x;
        public int y;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct MouseInput
    {
        public int X;
        public int Y;
        public int Data;
        public int Flags;
        public int Time;
        public IntPtr ExtraInfo;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct KeyboardInput
    {
        public short VirtualKey;
        public short ScanCode;
        public int Flags;
        public int Time;
        public IntPtr ExtraInfo;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct HardwareInput
    {
        public int uMsg;
        public short wParamL;
        public short wParamH;
    }

    [StructLayout(LayoutKind.Sequential)]
    private struct Input
    {
        public int Type;
        public InputUnion ui;
    }

    [StructLayout(LayoutKind.Explicit)]
    private struct InputUnion
    {
        [FieldOffset(0)]
        public MouseInput Mouse;
        [FieldOffset(0)]
        public KeyboardInput Keyboard;
        [FieldOffset(0)]
        public HardwareInput Hardware;
    }

    private const int KEYEVENTF_EXTENDEDKEY = 0x0001;
    private const int KEYEVENTF_KEYUP = 0x0002;
    private const int KEYEVENTF_SCANCODE = 0x0008;
    private const int KEYEVENTF_UNICODE = 0x0004;

    private const int MAPVK_VK_TO_VSC = 0;
    // private const int MAPVK_VSC_TO_VK = 1;


    public static void SendInputKeys(KeyUD[] keys)
    {
        Input[] inputs = new Input[keys.Length];

        for(int k=0; k<keys.Length; k++) {
            int vsc = NativeMethods.MapVirtualKey((int)keys[k].Key, MAPVK_VK_TO_VSC);

            inputs[k] = new Input();
            inputs[k].Type = 1; // KeyBoard = 1
            inputs[k].ui.Keyboard.VirtualKey = (short)keys[k].Key;
            inputs[k].ui.Keyboard.ScanCode = (short)vsc;
            inputs[k].ui.Keyboard.Flags = (keys[k].KeyStroke==KeyUD.Stroke.Down)?0:KEYEVENTF_KEYUP;
            inputs[k].ui.Keyboard.Time = 0;
            inputs[k].ui.Keyboard.ExtraInfo = IntPtr.Zero;
        }

        NativeMethods.SendInput(inputs.Length, inputs, Marshal.SizeOf(inputs[0]));
    }


    //private const int MOUSEEVENTF_MOVE        = 0x0001;
    //private const int MOUSEEVENTF_LEFTDOWN    = 0x0002;
    //private const int MOUSEEVENTF_LEFTUP      = 0x0004;
    //private const int MOUSEEVENTF_VIRTUALDESK = 0x4000;
    //private const int MOUSEEVENTF_ABSOLUTE    = 0x8000;
    private const int MOUSEEVENTF_WHEEL = 0x0800;

    private static Input MakeMouseWheelData(int amountOfMove, IntPtr extraInfo)
    {
        Input input = new Input();
        input.Type = 0; // MOUSE = 0
        input.ui.Mouse.Flags = MOUSEEVENTF_WHEEL;
        input.ui.Mouse.Data = amountOfMove;
        input.ui.Mouse.X = 0;
        input.ui.Mouse.Y = 0;
        input.ui.Mouse.Time = 0;
        input.ui.Mouse.ExtraInfo = extraInfo;
        return input;
    }

    public static void SendMouseWheel(int amountOfMove)
    {
        Input[] inputs = new Input[]{MakeMouseWheelData(amountOfMove, IntPtr.Zero)};
        NativeMethods.SendInput(inputs.Length, inputs, Marshal.SizeOf(inputs[0]));
    }


    private const int SRCCOPY = 13369376;
    private const int CAPTUREBLT = 1073741824;

    public static Bitmap CaptureActiveWindow()
    {
        //アクティブなウィンドウのデバイスコンテキストを取得
        IntPtr hWnd = NativeMethods.GetForegroundWindow();
        IntPtr winDC = NativeMethods.GetWindowDC(hWnd);

        if (hWnd==IntPtr.Zero){return null;}

        //ウィンドウの大きさを取得
        RECT winRect = new RECT();
        NativeMethods.GetWindowRect(hWnd, ref winRect);
        Bitmap bmp = new Bitmap(winRect.right - winRect.left, winRect.bottom - winRect.top);
        Graphics g = Graphics.FromImage(bmp);
        //Graphicsのデバイスコンテキストを取得
        IntPtr hDC = g.GetHdc();
        //Bitmapに画像をコピーする
        NativeMethods.BitBlt(hDC, 0, 0, bmp.Width, bmp.Height, winDC, 0, 0, SRCCOPY);
        g.ReleaseHdc(hDC);
        g.Dispose();
        NativeMethods.ReleaseDC(hWnd, winDC);

        return bmp;
    }

    public static bool SetProcessDPIAware()
    {
        return NativeMethods.SetProcessDPIAware();
    }
}


class CopyTeamsChat : Form
{
    Button btnStart;
    Button btnAbort;
    NumericUpDown nudMouseScroll;

    System.Windows.Forms.Timer tmr;
    int _countForStartDelay;
    bool _abortReq;
    bool _timerIsRunning;
    List<MemoryStream> _msBuffer;
    readonly int IntervalInMs   = 300;
    readonly int StartDelayInMs = 3000;
    int _countOfSavedImage;

    CopyTeamsChat()
    {
        NativeUtil.SetProcessDPIAware();

        _countForStartDelay = 0;
        _abortReq = false;
        _timerIsRunning = false;
        _countOfSavedImage = 0;

        tmr = new System.Windows.Forms.Timer();
        tmr.Interval = IntervalInMs;
        tmr.Tick += (s,e)=>{
            if ( _abortReq ) {
                // 停止させる
                tmr.Stop();
                btnStart.Enabled = true;
                //btnSave.Enabled = (_msBuffer.Count>0);
                nudMouseScroll.Enabled = true;
                _abortReq = false;
                _timerIsRunning = false;
                return;
            }

            if ( _countForStartDelay > 0 ) {
                _countForStartDelay--;
                int inMsecForShowingCountDown = _countForStartDelay * IntervalInMs;
                Text = (inMsecForShowingCountDown/1000).ToString() + "." + ((inMsecForShowingCountDown%1000)/100).ToString();
            }
            else{
                tmr.Stop();
                NativeUtil.SendMouseWheel((int)nudMouseScroll.Value);

                Screen curScreen =  Screen.FromControl(this);
                Bitmap bmp = new Bitmap(curScreen.Bounds.Width, curScreen.Bounds.Height);
                Graphics g = Graphics.FromImage(bmp);
                //画面全体をコピーする
                g.CopyFromScreen(new Point(curScreen.Bounds.Left, curScreen.Bounds.Top), new Point(0, 0), bmp.Size);
                g.Dispose();

                bmp.Save(@"img/TeamsCapture" + _countOfSavedImage.ToString().PadLeft(6, '0') + ".png", System.Drawing.Imaging.ImageFormat.Png);
                _countOfSavedImage++;

                Text = "SavedCount:" + _countOfSavedImage.ToString();
                tmr.Start();
            }
        };

        nudMouseScroll = new NumericUpDown(){
            Location = new Point(0,0),
            Width = 100,
            Maximum = 1000,
            Value = 200,
            Minimum = -1000,
        };
        Controls.Add(nudMouseScroll);

        btnStart = new Button(){
            Location = new Point(0,40),
            Size = new Size(150,30),
            Text = "Start Injection",
        };
        btnStart.Click += (s,e)=>{
            btnStart.Enabled = false;
            //btnSave.Enabled = false;
            nudMouseScroll.Enabled = false;
            if ( !_timerIsRunning ) {
                _msBuffer = new List<MemoryStream>();
                _timerIsRunning = true;
                _countForStartDelay = StartDelayInMs/IntervalInMs;
                tmr.Start();
            }
        };
        Controls.Add(btnStart);


        btnAbort = new Button(){
            Location = new Point(0,80),
            Size = new Size(150,30),
            Text = "Stop(Abort)",
        };
        btnAbort.Click += (s,e)=>{
            _abortReq = true;
        };
        Controls.Add(btnAbort);
    }

    [STAThread]
    static void Main(string[] args)
    {
        Application.Run(new CopyTeamsChat());
    }
}

参考サイト

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

C# - XMLをTreeViewに表示する (XElementクラスで探索して力技でTreeNodeに登録)

まえがき

arxmlを解析してなにかする便利ツールをつくろうと思ったが、XMLが大きすぎてノード関係性がサッパリわからないので、見やすくする&検索できるようにしたかった。

画面キャプチャ

image.png

入力サンプルデータ

Sample.xml
<?xml version="1.0" encoding="utf-8"?>
<SampleClass xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <SampleFieldPoint>
    <X>10</X>
    <Y>20</Y>
  </SampleFieldPoint>
  <SampleFieldSize>
    <Width>30</Width>
    <Height>40</Height>
  </SampleFieldSize>
</SampleClass>

ソースコード

XmlTreeView.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using System.Drawing;
using System.Windows.Forms;


class XmlTreeView : Form
{
    TextBox txtTreePath;
    TextBox txtSearchKeyword;
    ListView lsv;
    TreeView trv;

    XmlTreeView(string path)
    {
        ClientSize = new Size(1200,500);

        XElement xml = XElement.Load(path);

        SplitContainer spl1;
        SplitContainer spl2;
        Controls.Add(spl1 = new SplitContainer(){
            Dock = DockStyle.Fill,
            Orientation = Orientation.Horizontal,
            FixedPanel = FixedPanel.Panel1,
            Panel1MinSize = 50,
            Panel2MinSize = 50,
        });
        spl1.Panel2.Controls.Add(spl2 = new SplitContainer(){
            Dock = DockStyle.Fill,
            Orientation = Orientation.Horizontal,
            // FixedPanel = FixedPanel.Panel1,
            Panel1MinSize = 50,
            Panel2MinSize = 50,
        });

        Load += (s,e)=>{
            spl1.SplitterDistance =  45;
            spl2.SplitterDistance =  150;
        };

        spl1.Panel1.Controls.Add( txtTreePath = new TextBox(){
            Location = new Point(0, 0),
            Width = 1000,
            ReadOnly = true,
        });

        Button btnSearch;
        spl1.Panel1.Controls.Add( btnSearch = new Button(){
            Location = new Point(0, 22),
            Width = 70,
            Text = "Search",
        });
        btnSearch.Click += (s,e)=>{SearchNode();};

        spl1.Panel1.Controls.Add( txtSearchKeyword = new TextBox(){
            Location = new Point(80, 25),
            Width = 620,
        });
        txtSearchKeyword.KeyDown += (s,e)=>{
            if ( e.KeyData == Keys.Enter ) { SearchNode(); }
        };


        spl2.Panel1.Controls.Add(lsv = new ListView(){
            Dock = DockStyle.Fill,
            View = View.Details,
            MultiSelect = false,
            HideSelection = false,
            FullRowSelect = true,
            GridLines = true,
        });
        lsv.Columns.AddRange(new ColumnHeader[]{
            new ColumnHeader(){Name="Type"     , Text="Type"         , Width=  70, TextAlign=HorizontalAlignment.Left},
            new ColumnHeader(){Name="Name"     , Text="Name or Value", Width=  70, TextAlign=HorizontalAlignment.Left},
            new ColumnHeader(){Name="Location" , Text="Location"     , Width=1000, TextAlign=HorizontalAlignment.Left},
        });
        lsv.DoubleClick += (s,e)=>{Lsv_DoubleClick();};

        spl2.Panel2.Controls.Add( trv = new TreeView(){
            Dock = DockStyle.Fill,
            HideSelection = false,
        });
        trv.AfterSelect += Trv_AfterSelect;


        var rootNode = new TreeNode(xml.Name.LocalName);
        rootNode.Name = rootNode.Text;
        trv.Nodes.Add(rootNode);
        XmlAddNodes(rootNode, xml);
    }
    // elemの子をnodeに追加する
    void XmlAddNodes(TreeNode node, XElement elem)
    {
        var t = elem.Elements(); // 直下の子ノードたち
        bool hasChild=false;
        foreach ( XElement elemChild in t)
        {
            hasChild=true;
            string s = elemChild.Name.LocalName;
            var nodeChild = new TreeNode(s);
            nodeChild.Name = s;
            node.Nodes.Add(nodeChild);
            XmlAddNodes(nodeChild, elemChild);
        }
        if(!hasChild){
            node.Text += " = " + elem.Value;
            node.Tag   = elem.Value;
        }
    }


    void Trv_AfterSelect(object sender, TreeViewEventArgs e)
    {
        txtTreePath.Text = GetLocationText(e.Node);
    }

    string GetLocationText(TreeNode node)
    {
        string s = node.Text;

        while(node.Parent!=null){
            node = node.Parent;
            s = node.Text + " > " + s;
        }

        return s;
    }


    void SearchNode()
    {
        string key = txtSearchKeyword.Text;

        if(key==""){return;}

        lsv.Items.Clear();
        lsv.BeginUpdate();

        var nodes = trv.Nodes.Find(key, true);
        foreach( var node in nodes ) {
            var item = new ListViewItem(new string[]{"TagName", node.Text, GetLocationText(node)});
            item.Tag = node;
            lsv.Items.Add(item);

        }

        lsv.EndUpdate();
    }

    void Lsv_DoubleClick()
    {
        if (lsv.SelectedItems.Count!=1){
            return;
        }

        var node = (TreeNode)lsv.SelectedItems[0].Tag;
        node.EnsureVisible();
        trv.SelectedNode = node;
    }

    [STAThread]
    static void Main(string[] args)
    {
        //Application.Run(new XmlTreeView(@"hogehoge.xml"));
        if (args.Length==1){
            Application.Run(new XmlTreeView(args[0]));
        }
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ゴースト機能について

はじめに

今回初めて記事を書かせていただきます。
今回の内容はゴースト機能について書いていきたいと思います。

ゴースト機能について

以下にあるのが自分のゲーム(car race game)で使用しているゴースト機能スクリプトです。

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

public class Recorder : MonoBehaviour{
// 操作キャラクター
[SerializeField]
public CarScript CarScript;
// AnimatorController
public Animator animator;
// 現在記憶しているかどうか
public bool isRecord;
// 保存するデータの最大数
[SerializeField]
public int maxDataNum = 90000;
// 記録間隔
[SerializeField]
public float recordDuration = 0.01f;
// 経過時間
public float elapsedTime = 0f;
// ゴーストデータ
private GhostData0 ghostData0 = new GhostData0();
private GhostData1 ghostData1 = new GhostData1();
// 再生中かどうか
public bool isPlayBack;
// ゴースト用キャラ
[SerializeField]
public GameObject ghost;
// ゴーストデータが1周りした後の待ち時間
[SerializeField]
public float waitTime = 2f;
// 保存先フォルダ
public string saveDataFolder = "/Projects/Ghost";
// 保存ファイル名
public string saveFileName = "/ghostdata.dat";
// Start is called before the first frame update
void Start()
{
animator = CarScript.GetComponent ();
}
// Update is called once per frame
void Update()
{
if (isRecord) {

    elapsedTime += Time.deltaTime;

    if (elapsedTime >= recordDuration) 
    {
        ghostData1.posLists.Add (CarScript.transform.position);
        ghostData1.rotLists.Add (CarScript.transform.rotation);

        elapsedTime = 0f;

        // データ保存数が最大数を超えたら記録をストップ
        //if (ghostData1.posLists.Count >= maxDataNum) 
        //{
        //  StopRecord ();
        //}
    }   
  }
}
// ゴーストデータクラス

[Serializable]
public class GhostData0 {
// 位置のリスト
public List posLists = new List();
// 角度リスト
public List rotLists = new List();

}
// ゴーストデータクラス
[Serializable]
public class GhostData1 {
// 位置のリスト
public List posLists = new List();
// 角度リスト
public List rotLists = new List();

}
// キャラクターデータの保存
public void SetupRecord(){
Load();
StartRecord();
StartGhost();
}
public void StartRecord() {
//Debug.Log ("On StartRecord");
// 保存する時はゴーストの再生を停止
//StopAllCoroutines ();
//StopGhost ();
isRecord = true;
elapsedTime = 0f;
ghostData1 = new GhostData1 ();
Debug.Log ("StartRecord");
}

// キャラクターデータの保存の停止
public void StopRecord() {
//Debug.Log ("On StopRecord");
isRecord = false;
Debug.Log ("StopRecord");
}

// ゴーストの再生ボタンを押した時の処理
public void StartGhost() {
//Debug.Log ("On StartGhost");
Debug.Log ("StartGhost");
if (ghostData0 == null) {
Debug.Log ("ゴーストデータがありません");
} else {
Debug.Log("データがあります");
//isRecord = false;
isPlayBack = true;
ghost.transform.position = ghostData0.posLists [0];
ghost.transform.rotation = ghostData0.rotLists [0];
ghost.SetActive (true);
StartCoroutine (PlayBack ());
}
}

// ゴーストの停止
public void StopGhost() {
//Debug.Log ("On StopGhost");
Debug.Log ("StopGhost");
StopAllCoroutines ();
isPlayBack = false;
ghost.SetActive (false);
}
// ゴーストの再生
IEnumerator PlayBack() {
Debug.Log ("On PlayBack");
var i = 0;
var ghostAnimator = ghost.GetComponent ();

Debug.Log ("データ数: " + ghostData0.posLists.Count);

while (isPlayBack) {

    yield return new WaitForSeconds (recordDuration);

    ghost.transform.position = ghostData0.posLists [i];
    ghost.transform.rotation = ghostData0.rotLists [i];
    //ghostAnimator.SetFloat("Speed", ghostData.speedLists[i]);

    //if (ghostData.jumpAnimLists [i]) {
    //  ghostAnimator.SetTrigger ("Jump");
    //}

    i++;

    // 保存データ数を超えたら最初から再生
    if (i >= ghostData0.posLists.Count) {

        ghostAnimator.SetFloat ("Speed", 0f);
        ghostAnimator.ResetTrigger ("Jump");

        // アニメーション途中で終わった時用に待ち時間を入れる
        yield return new WaitForSeconds (waitTime);

        ghost.transform.position = ghostData0.posLists [0];
        ghost.transform.rotation = ghostData0.rotLists [0];

        i = 0;
    }
}

}
public void Save() {
if (ghostData1 != null) {
// GhostDataクラスをJSONデータに書き換え
var data = JsonUtility.ToJson (ghostData1);
// ゲームフォルダにファイルを作成
File.WriteAllText (Application.dataPath + saveDataFolder + saveFileName, data);
Debug.Log ("ゴーストデータをセーブしました");
}
}

public void Load() {

if (File.Exists (Application.dataPath + saveDataFolder + saveFileName)) {
    string readAllText = File.ReadAllText (Application.dataPath + saveDataFolder + saveFileName);
    // ghostDataに読み込んだデータを書き込む
    if (ghostData0 == null) {
        ghostData0 = new GhostData0 ();
    }
    JsonUtility.FromJsonOverwrite (readAllText, ghostData0);
    Debug.Log ("ゴーストデータ"+ readAllText +"をロードしました。");
}

}

void OnApplicationQuit() {
Debug.Log ("アプリケーション終了");
Save ();
}

}

このスクリプトではコースを走っているときに記録する箱とその時に違うデータを呼び出す箱を用意しています
では、なぜ用意するのでしょうか?
それはコースを走っているときに記録する箱と呼び出す箱が同じだと上書きされて呼び出したいデータが今は知っているデータに書き換えられてい舞うからです。

最後に

このスクリプトを使ったcar race ゲームでAOに合格することができました。

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

C#9.0 SourceGeneratorでReadonly構造体を生成するGeneratorを作ってみました。

こちらの記事を拝見し、
C# 9.0で加わったC# Source Generatorと、それで作ったValueObjectGeneratorの紹介

私も練習がてらに何か作ってみようかな、ということで作ってみました!

作ったもの

ReadonlyStructGenerator

構造体の宣言で必要なプロパティだけ記述すると、以下のコードを自動生成するGeneratorです。
Record型の構造体版のようなものをイメージしました)

  • 構造体に readonly 修飾子を付加
  • プロパティを初期化するコンストラクタの追加
  • IEquatable<T> の実装
  • object.Equals(), object.GetHashCode() and object.ToString()のオーバーライド
  • 演算子 (==, !=)のオーバーロード

ソースコード
https://github.com/pierre3/ReadonlyStructGenerator

使いかた

  1. partial キーワードを付けて構造体を宣言します
  2. 構造体にReadonlyStructGenerator.ReadonlyStructAttribute属性を付与します。
  3. initアクセサーを付けたプロパティを追加します。
[ReadonlyStructGenerator.ReadonlyStruct]
public partial struct Point
{
    public int X { get; init; }
    public int Y { get; init; }
}

ビルドすると以下のコードが自動生成されます。

#nullable enable
using System;
namespace SampleConsoleApp
{
    readonly partial struct Point : IEquatable<Point>
    {
        public Point(int x,int y) => (X,Y) = (x,y);
        public bool Equals(Point other) => (X,Y) == (other.X,other.Y);
        public override bool Equals(object? obj) => (obj is Point other) && Equals(other);
        //注意)HashCode.Combine() のオーバーロードが引数8つまでしか対応していないので
        //プロパティの数が9個以上あるとエラーになってしまいます…
        public override int GetHashCode() => HashCode.Combine(X,Y);
        public static bool operator ==(Point left, Point right) => left.Equals(right);
        public static bool operator !=(Point left, Point right) => !(left == right);
        public override string ToString() => $"{nameof(Point)}({X},{Y})";
    }
}
  • initアクセサーを付けたプロパティのみがコンストラクタによる初期化や、同値比較の対象となります(get only なプロパティは対象外)。
  • また、自前でコンストラクタを述した場合コンストラクタは自動生成の対象外となります。
[ReadonlyStructGenerator.ReadonlyStruct]
public partial struct Vector3
{
    public float X { get; init; }
    public float Y { get; init; }
    public float Z { get; init; }

    public float Norm { get; }    //自動生成対象外

    //コンストラクタをこちらで定義した場合自動生成対象外
    public Vector3(float x, float y, float z)
    {
        (X, Y, Z) = (x, y, z);
        Norm = (float)Math.Sqrt(X * X + Y * Y + Z * Z);
    }
}
#nullable enable
using System;
namespace SampleConsoleApp
{
    readonly partial struct Vector3 : IEquatable<Vector3>
    {
        public bool Equals(Vector3 other) => (X,Y,Z) == (other.X,other.Y,other.Z);
        public override bool Equals(object? obj) => (obj is Vector3 other) && Equals(other);
        public override int GetHashCode() => HashCode.Combine(X,Y,Z);
        public static bool operator ==(Vector3 left, Vector3 right) => left.Equals(right);
        public static bool operator !=(Vector3 left, Vector3 right) => !(left == right);
        public override string ToString() => $"{nameof(Vector3)}({X},{Y},{Z})";
    }
}

Source Generator を作る際の注意点

Source Generator のプロジェクトの構成

Visual Studio でビルドして使う場合は、Visual Studio 自身が.NetFrameworkで動いているため、ターゲットフレームワークをnetstandard2.0としないと動作してくれません。
また、Microsoft.CodeAnalysis系のライブラリも下記バージョンでないと動作しませんでした。(2020/12/6現在)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  <LangVersion>preview</LangVersion>
  </PropertyGroup>
  <ItemGroup>
      <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0-5.final" PrivateAssets="all" />
      <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.1" PrivateAssets="all" />
  </ItemGroup>
</Project>

ただし、dotnet コマンドでビルドする分には.NET5.0でも問題なく動作します。
(手元の環境で試したところ、.NET5.0, Microsoft.CodeAnalysis.CSharp v3.8.0, Microsoft.CodeAnalysis.Analyzers v3.3.2 で動作しました。)

Generatorを利用する側の参照設定

利用側のプロジェクトでは、Generatorをアナライザーとして参照する必要があります。
(ProjectReferenceに OutputItemType="Analyzer" ReferenceOutputAssembly="false" を付与)

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\ReadonlyStructGenerator\ReadonlyStructGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>

デバッグ

開発中は、C#のシンタックスツリーとかセマンティックモデルだとか使い慣れないオブジェクトを触るため、トレースデバッグは必須です。
Source GeneratorではInitializeメソッドに下記コードを記述することでトレース実行が可能となります。
(実行時ではなくビルド時にSystem.Diagnostics.Debugger.Launch();の行でブレイクされます。)

public void Initialize(GeneratorInitializationContext context)
{
    System.Diagnostics.Debugger.Launch();
}

何かおかしかったら再起動

更新したコードが反映されないだとか、コードは正しいのにエラーが解消されないだとか挙動がおかしくなった場合はVisual Studioを再起動すると直ることがあります。

感想

Source Generatorを作ってみた感想ですが、ドキュメントやサンプルが少ないのと動きが安定していないということもあって、現時点では複雑な機能を作るのはしんどそうな気がします。
が、この機能自体はいろいろ面白いことができそうなので、何か思いついたらまたチャレンジしてみたいと思います。

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

Blazor Tutorialまとめ(EP06~)

本家動画はこちら
EP01~05はこちら

EP06:JavaScriptとの相互運用

  • razorページでJavaScriptを使うには、まずは@inject IJSRuntime JSRuntime;を追加する必要があるよ。
  • jsフォルダ内の「site.js」を追加して、同ファイルに以下の関数を追加したのでそれを呼んでみよう。
function saveMessage(){
    alert('レコードの保存が完了しました。');
}
  • _Host.cshtml」の blazor.server.js の下に <script src="~/js/site.js"></script> を追加しよう。
  • Saveボタン押下時の処理(SaveAuthorメソッド)に追加したいので、同メソッドに await JSRuntime.InvokeVoidAsync("saveMessage"); を追加しよう。
  • あとこの関数は非同期なので予備元のSaveAuthorメソッドもprivate async Task SaveAuthor()としておこう。
  • この手のテストは検証があると面倒なので@*<DataAnnotationsValidator />*@@* *@ で括って検証を無効にしておこう。
  • 動かしてみるとちゃんとダイアログが表示されたね。
  • それじゃ次にJavaScriptに引数を渡してみよう。まずはJavaScriptに引数を足すよ。
function saveMessage(firstName, lastName) {
    alert(firstName + " " + lastName + 'のレコードの保存が完了しました。');
}
  • 次にInvokeVoidAsyncにも引数を足すよ。InvokeVoidAsyncは可変個数の引数を指定できるよ。
await JSRuntime.InvokeVoidAsync("saveMessage", author.FirstName, author.LastName);

(動画じゃauthor.FirstName, author.LastNameを渡さずに、直接一回変数に入れてるけどなんか意味あるんかな?)

  • 続いてJavaScriptでDOMを操作してみよう。まずはJavaScriptを修正するよ。
function saveMessage(firstName, lastName) {
    document.getElementById('divServiceValidations').innerText = firstName + " " + lastName + 'のレコードの保存が完了しました。';
}

(ローカルで動かしてたら「innerTest」ってタイポしてて動かんかった・・・VisualStudioならTypeScript簡単に使えるし(必要なものはNugetで勝手にとってきてくれる)トランスパイルの時にエラー出してくれるTypeScript使おう。型もあるしほぼ書き方変わらんし)

function saveMessage(firstName: string, lastName: string) {
    document.getElementById('divServiceValidations').innerText = firstName + " " + lastName + 'のレコードの保存が完了しました。';
}
  • 操作対象の<div id="divServiceValidations" class="col-10 alert alert-info"></div>を「Authors.razor」の<EditForm>の前に追加するよ。
  • 動かしてみるとメッセージが追加した<div id="divServiceValidations">に表示されるね。
  • 次は「Save」を押すと「first name」にフォーカスが映るようにしてみよう。
  • まずは@ref属性を「first name」の<InputText>に追加するよ。
<InputText @ref="firstnameText" class="form-control col-3" @bind-Value="author.FirstName" placeholder="first name" />
  • 続いてコード部にElementReference firstnameText;を追加するよ。
  • ここで一旦コンパイルしてみよう。コンポーネントを変換できない、みたいなエラーが出るね。
  • @refはRazorコンポーネントには指定できないので、<InputText><input>に変えてやる必要があるよ。
  • @bind-Value@bindに変える必要があるので以下のようになるよ。
<input @ref="firstnameText" class="form-control col-3" @bind="author.FirstName" placeholder="first name" />

(ElementReference firstnameText;だとエラーになるけどInputText firstnameText;だとエラーにならんな。この辺は型チェックしてるってことか)

  • それじゃフォーカスをセットするスクリプトを書いて、
function setFocusOnElement(element: HTMLElement) {
    element.focus();
}

(動画ではJavaScriptだからもちろん「: HTMLElement」は無いよ)

  • あとはメッセージ表示の時と同じように関数を呼ぶだけだよ。動かしてみるとちゃんとフォーカスが移動してるね。
await JSRuntime.InvokeVoidAsync("setFocusOnElement", firstnameText);

(一々JS使わんといかんの面倒だな。WebForm時代みたいにSetFocus関数呼ぶだけ、とかにならんかなぁ・・・。以下みたいな感じ)

public InputText lastnameText;
private async Task SaveAuthor() {
    lastnameText.SetFocus();//今のところそんなメソッドはない。
}

(ID指定すればInputTextのままでもいけるな)

<InputText id="firstnameText" @ref="firstnameText" class="form-control col-3" @bind-Value="author.FirstName" placeholder="first name" />
@code {
    public InputText lastnameText;

    private async Task SaveAuthor()
    {
        await JSRuntime.InvokeVoidAsync("setFocusOnID", lastnameText.AdditionalAttributes["id"]);
    }
}
function setFocusOnID(element: string) {
    document.getElementById(element).focus();
}
  • 次はJavaScriptからとった配列でリストボックスの一覧を初期化してみよう。JavaScriptは以下のようなのだよ。
function getCities(): string[] {
    var cities: string[] = ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix', 'Philadelphia', 'San Antonio',
        'San Diego', 'Dallas', 'San Jose', 'Austin', 'Jacksonville', 'Fort Worth', 'Columbus', 'San Francisco',
        'Charlotte', 'Indianapolis', 'Seattle', 'Denver', 'Washington'];
    return cities;
}

(動画では(ry)

  • あとはstring[] Citiesプロパティを追加して、描画後に実行される「OnAfterRenderAsync」に初回かつCitiesがnullの時にJavaScriptから値を取ってきて StateHasChanged();で反映させるよ。
  • コード見ればわかるけど、戻り値なしのときはInvokeVoidAsync、戻り値ありの時はInvokeAsync<TValue>でTValueに型を指定するんだね。
protected override async Task OnAfterRenderAsync(bool firstRender)
{
    if (firstRender && Cities == null)
    {
        Cities = await JSRuntime.InvokeAsync<string[]>("getCities");
        StateHasChanged();
    }
}
  • あとはforeachでoption追加を回せば追加されるね。
<select class="form-control col-3">
    @if (@Cities != null){
        <option value="">select city..</option>
        @foreach (var city in Cities){
            <option value="@city">@city</option>
        }
    }
</select>

(実アプリじゃあんまり使わないパターンかなぁ。選択とかEnumとか固定配列とかDBからとるパターンしか使わなそう)

EP07:Razorコンポーネントの再利用

  • 今回はRazorコンポーネントの再利用について話すよ。
  • Razorコンポーネントは2種類あって、一つ目は「ページ」。
  • 今までにも出てきた頭に@pageって書いてあるやつだね。ブラウザからこれらのコンポーネントに移動してWebページとして利用できるよ。
  • もう一つは共有可能なコンポーネントでページに組み込んで利用できるよ。今回はこの共有可能なコンポーネントの話だよ。
  • さて、今まで「著者(Author)の名前とかの情報を入力して保存する」っていうページを作ってきたけど、これと似たようなので「出版社(Publisher)の名前とかの情報を入力して保存する」というのを作ったよ。
  • ここでコードを見てみるとどっちのページにも<div id="divServerValidations">っていうタグがあるね。
  • いわゆる重複コードだけど、Razorコンポーネントの再利用をすると、こういうのことが起きないようにできるよ。
  • では早速ソリューションエクスプローラ右クリックからRazorコンポーネントを追加してみよう。名前は「ServerValidations.razor」にしよう。
  • とりあえずここには<div id="divServerValidations">とメッセージだけを追加してみよう。
ServerValidations.razor
<div id="divServiceValidations" class="col-10 alert alert-info">
    保存完了
</div>
@code {
}
  • 呼び出すのは簡単で<ServerValidations></ServerValidations>って書くだけだよ。実行してみるとちゃんとページに組み込まれてるのがわかるね。
  • それじゃ次はパラメーターでメッセージの表示/非表示を切り変えてみよう。コンポーネント側(子)はこんな感じでパラメーターを追加できるよ。
ServerValidations.razor
@if (IsVisible) {
    <div id="divServiceValidations" class="col-10 alert alert-info">
        保存完了
    </div>
}

@code {
    [Parameter]
    public bool IsVisible { get; set; }
}
  • 呼び出し側(親)でも変数を宣言して、コンポーネントのタグにプロパティとの対応を書いてあげるよ。
Author.razor
@page "/authors"
<ServerValidations IsVisible="IsVisible"></ServerValidations>

@code {
    public bool IsVisible { get; set; }

    private async Task SaveAuthor() {
        IsVisible = true;
(以下略)
  • 動かしてみると、ちゃんと「Save」ボタンを押したら<div id="divServiceValidations">の内容が表示されるね。
  • 今度は「親の方で書いたHTMLを子の方に表示させる」というのをやってみよう。
  • まず「ServerValidations.razor」の方はRenderFragment型のプロパティを追加してHTML部に表示させるよ。
ServerValidations.razor
@if (IsVisible)
{
    <div id="divServiceValidations" class="col-10 alert alert-info">
        @ChildContent : 保存完了
    </div>
}

@code {
    [Parameter]
    public bool IsVisible { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get;  set; }
}
  • 呼び出し側(親)の方は<ServerValidations>のタグの中に「ServerValidations.razor」の@ClildContentで表示したいものを書くよ。HTMLとかも書けるよ。

(なんかわからんけど↓のコードだと実行エラーになる・・・)
→「ServerValidations.razor」のChildContentClildContentって打ってたのが原因だった。このプロパティ名って固定なのな。

Author.razor
@page "/authors"
<ServerValidations IsVisible="IsVisible">
    <span>著者:</span>@RecordName 
</ServerValidations>

@code {
    public bool IsVisible { get; set; }
    public string RecordName { get; set; }

    private async Task SaveAuthor() {
        IsVisible = true;
        RecordName = author.FirstName + " " + author.LastName;
(以下略)
  • 動かしてみると呼び出し側(親)の方で<ServerValidations>のタグの中に書いた内容がコンポーネント側(子)の方に表示されテルルがわかるね。
  • パラメータにbool Resultとかを足してやればエラー表示もできるね。

(動画では styleとか指定してるけど省略)

@if (IsVisible)
{
    @if (Result){
        <div id="divServiceValidations" class="col-10 alert alert-info">
            @ChildContent  保存完了
        </div>
    }else{
        <div id="divServiceValidations" class="col-10 alert alert-danger">
            サーバーエラーです。管理者に問い合わせてください。
        </div>
    }
}

@code {
    [Parameter]
    public bool IsVisible { get; set; }

    [Parameter]
    public bool Result { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }
}
  • あとこのメッセージを×で閉じれるようにしてみよう。ページと同じようにイベントを記述できるよ。
@if (IsVisible){
    @if (Result){
        <div id="divValidationMessage" class="col-10 alert alert-danger row">
            <div class="col-11" style="text-align:left">
                @ChildContent  保存完了
            </div>
            <div class="col-1" style="text-align:right">
                <a style="text-underline-position:below; cursor:pointer" @onclick="CloseValidation">x</a>
            </div>
        </div>
    }else{
        <div id="divValidationMessage" class="col-10 alert alert-danger row">
            <div class="col-11" style="text-align:left">
                サーバーエラーです。管理者に問い合わせてください。
            </div>
            <div class="col-1" style="text-align:right">
                <a style="text-underline-position:below; cursor:pointer" @onclick="CloseValidation">x</a>
            </div>
        </div>
    }
}

@code {
    [Parameter]
    public bool IsVisible { get; set; }

    [Parameter]
    public bool Result { get; set; }

    [Parameter]
    public RenderFragment ChildContent { get; set; }

    public void CloseValidation(){
        IsVisible = false;
    }
}

(そういやこれ、タグに全部プロパティを一個一個書くより@ref使ったほうがいいな)
(IsVisibleとかの[Parameter]は消さないと「パラメータ指定してねーぞ」って警告が出た)

(略)
<ServerValidations @ref="ServerValidationsCtrl">
    <span>著者:@RecordName</span>
</ServerValidations>
(中略)
@code {
    public ServerValidations ServerValidationsCtrl;

    public void SaveAuthor(){
        RecordName = author.FirstName + " " + author.LastName;
        ServerValidationsCtrl.IsVisible = true;
        ServerValidationsCtrl.Result = false;
    }
(略)
  • 実際に再利用してみよう。簡単だね。

EP08:Razorコンポーネントのライフサイクル

  • 今回はRazorコンポーネントのライフサイクルについて話すよ。
    (この章、自動翻訳が微妙過ぎて何言ってんのかわからん・・・、他のWebサイトも含めてまとめていこ)
    BlazorのLifecycleとShouldRenderの挙動について
    AspNetCore.Docs.ja-jp - lifecycle.md/GitHub

  • 初期化イベントだけど次の順で発生するよ

  • OnInitialized OnInitializedAsync:アプリの設定とかタイトルとか設定するといいね。OnInitializedAsyncは非同期だからDB読んだりしてもいいね。

  • SetParametersAsync(動画では言ってないけど↑にあったので追加)Razorコンポーネントにパラメーターがセットされる前に呼ばれるよ。渡されたパラメーターのチェックができるね。セットされたプロパティの値はparameters.GetValueOrDefault<プロパティの型>("プロパティ名")で取れるよ。await base.SetParametersAsync(parameters);を呼ばないようにすれば値は反映されないよ。他と違って非同期しかないっぽいよ。

  • OnParametersSet OnParametersSetAsync:Razorコンポーネントにパラメーターがセットされたあとに呼ばれるよ。渡されたパラメーターを元に何か処理するならここだね。

  • OnAfterRender OnAfterRenderAsync:画面がレンダリングされた後に呼ばれるよ。DOMも作られてるからJavaScript呼ぶならここだね。初期化の流れで何かするならここが最後のチャンスだね。例えばDBから値を取れなかった時のリトライ処理とか。

  • 言わずもがなだけど、Asyncってついてる奴は非同期メソッドだね。

  • あと一回表示したあとは画面更新前にShouldRenderっていうイベントが起きて、ここでfalseを返すと画面更新をやめるよ。

  • デフォルトではないけどIDisposableを継承してDisposeを実装すればページ遷移時の後処理を実装できるね。
     (RazorコンポーネントにIDisposable継承させるってどうやるんやろ?)

EP09:Razor Component Libraries

EP10:Call REST API | CRUD Methods

EP11:Authentication | Out of the box

EP12:Authentication | Custom AuthenticationStateProvider

EP13:Layouts | Login Pages

EP14:HttpClient | Login User

EP15:IHttpClientFactory | Login User

EP16:Sending JWT token & Building Request Middleware

EP17:Register User & Generate JWT

EP18:Role-based Authorization

EP19:Policy-based Authorization

EP20:Procedural Logic | Authentication & Authorization in C

EP21:Templated Components | Html Table

EP22:Razor Components | EventCallback

EP23:Event Handling

EP24:GridView Header Filter

EP25:Gridview Paging

EP26:Spinner or Activity Indicator : EP26

EP27:Code Faster Using dotnet watch run

EP28:Deploy to IIS

EP29:Deploy to Azure App Services

EP30:Handling Exceptions

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

自分のサービスにモブプログラミングでリグレッションテストを導入している話

この内容は ソフトウェアテスト Advent Calendar 2020の6日目の記事です

自分のサービスにリグレッションテストを書いていなかった

僕は趣味でマンガログサービス「マンガ読んだ!!」を作っています。このサービスは規模が小さいこともあり特にリグレッションテストを書いていませんでした。そのため本番環境にデプロイした後に特定のページがエラーになっていたこともしばしばありました。とはいえ他にもやることはあったので、テストは入れたい入れたいとは思いつつ、ずっと入れていない状態でした。

SPAでURLの存在チェックするテストは意味がない

で、ある時、またページがエラーになっていたので、せめてHttpClientを使ってURLが生きているかのチェックをする簡単なテストを作れないかと思いました。しかし、マンガ読んだ!!はSingle Page Application(以下SPA)です。SPAでステータスコードが成功かのテストをしても(作り方にもよりますが)、失敗がソフト404エラーになるため全部成功か全部失敗の2択にしかなりません。SPAでテストしたければ、Seleniumなどのフレームワークで人間の操作を自動化してテストしてやる必要があり、ちょっとハードルが高めです。

//このテストは全部成功か全部失敗しかない。。。
[TestMethod]
public async Task UrlStatusTest()
{
    using var client = new HttpClient();
    (await client.GetAsync(@"https://manda-yonda.com")).IsSuccessStatusCode.IsTrue();
    (await client.GetAsync(@"https://manda-yonda.com/mangas")).IsSuccessStatusCode.IsTrue();
    (await client.GetAsync(@"https://manda-yonda.com/series")).IsSuccessStatusCode.IsTrue();
    (await client.GetAsync(@"https://manda-yonda.com/author")).IsSuccessStatusCode.IsTrue();
}

モブプロによるリグレッションテストの導入

そこで、ちょっと思いついて、自分よりテストとSeleinumの知識がありそうな人とモブプロ的にリグレッションテストが導入が出来たら面白いかなと思って、知人に声をかけてみました。この試みは予想以上に面白く、また成果が出ました。今日の時点でマンガ読んだ!!zoomガヤガヤ会と称して既に5回やっていますが、かなりリグレッションテストが機能してきています。そこで、ここまでの経緯をダイジェストで書いていこうと思います。

1回目のモブプロ

で、最初のモブプロです。Visual Studio Live Shareが上手く使えなかったので、一旦ドライバーは僕1人だけにしました。他の人は口を出します。ポイントとして出たのが待つ対象を何にするかという話でした。つまりテストでページ移動をする場合に、何がどうなったらページが移動したと判断するか。具体的にはプロダクトコードでページが切り替わったタイミングで待ち用のidなりclassなりを作って、そこに状態を入れる必要があります。

<div class="wait" state="@State"> //@StateはSPAのフレームワークで変数を入れる書き方

ここで自分の中では疑問だったidとclassの使い分けについて聞いてみました。これに関しては明確な答えが返ってきました。対象が一位になるならid、そうでなければclass。で、今回作るものは誰が使うか?テストが使う。逆に言えばテスト以外は使わない。つまりid一択。なるほど!と思いました。次にidのvalueですが、data-stateを進められました。これがあるとjsでdataset.stateを使えば簡単に書き換えられる。なるほど知らなかったのでこれは便利だなと思いました。

<div id="wait" data-state="@State"> 

これで、ページが変わって、DOMのレンダリングが終わった状態でstateに値を入れれば、それを待つ対象に出来ます。

DriverWait.Until(drv => drv.FindElement(By.Id("wait")).GetAttribute("data-state") == state);

後は待った上で、何をテストするかを決める必要があります。stateをそのまま対象にしても良いのですが、これは別にすることにしてタイトルをテストしました。

Assert.AreEqual(Driver.Title, "トップ");

当然プロダクトコードでは最初にtitleを書き換えて、最後にstateを書き換えます。これでSPAでトップページのタイトルをチェックするテストを書きました。大体2時間(雑談含む)ぐらい経ったのでここで終わりにしました。

2回目のモブプロ

2回目はまずこのサービスで抑えるべきページはいくつあるのかという話になりました。全ページだと30万以上ありますが、ベースとなるページは8画面でこのテストが書ければ十分となりました。ここまでzoomで会話しながら何となく画面共有しているテキストエディタに適当な単語を書きながら話をしていました。そしたら今書いたメモを整理して日本語にすれば、それがテストだと言われました。なるほど。そういうアプローチがあるのかと感心してしまいました。清書したメソッド名が以下です。

public void トップ画面を表示()
public void トップ画面から移動してマンガ画面を表示()
public void トップ画面からマンガ画面に移動してマンガ詳細画面を表示()
public void トップ画面から移動してシリーズ画面を表示()
public void トップ画面からシリーズ画面に移動してシリーズ詳細画面を表示()
public void トップ画面から移動して作者画面を表示()
public void トップ画面から作者画面に移動して作者詳細画面を表示()
public void トップ画面からログイン画面に移動してログインしてメイン画面を表示してユーザー名をクリックしてユーザー画面に移動()

ここで1つ方針を決めました。一旦ダーティでも良いので、8画面のテストを作りきる。この方針は後々面白い効果を産みました。結局2回目は3個のテストを書いた所で終わりました。

3回目のモブプロ

3回目のモブプロでは、前回ダーティでも良いことにしたので、Stateを0,1,2の数字で扱っていましたが、ここから3,4となっていくのはダーティというより煩雑なので、文字に変えました。

<div id="wait" data-state="0"> <div id="wait" data-state="1"><div id="wait" data-state="top"> <div id="wait" data-state="manga"> 

それ以外は引き続きダーティルールで、作ったメソッドをコピペしては必要な所だけ書き換えていきました。これに関しては僕を含め、皆、思う所はありつつ、スルーという状況でした。大分テストが揃ってきた時に、「流石にこの作り方罪悪感半端ない」と言ったら、みんな同じ気持ちだったので盛り上がりました。最終的には「まあこれが共通認識なら」となりました。もしテストの数がもっと多かったら流石にどこかで不満爆発していたかもしれませんが。テストは7/8書いて残り1個で終わりにしました。

4回目のモブプロ

前回、詳細ページはタイトルが動的に決まるので、それをURLから取るようにしていたのですが、この方法には問題があるという話をしたら、むしろリグレッションテストテストならユニットテストとは違い、前のページでクリックした名前を変数に残してそれと比較するのがスジだろうとなりました。確かに流れをテストするならこれが正しい方法な気がしました。

さて、今回はログインを含んだ最後のテストを書きました。とはいえ、テキスト入力したりボタンクリックしてログインするのはSeleinumのお家芸なので、トライ&エラーはありつつ実装していきました。ログイン出来て画面移動が終わったところで一旦テストを分けて、最後のアクションのテストも書きました。

public void トップ画面からログイン画面に移動してログインしてメイン画面を表示してユーザー名をクリックしてユーザー画面に移動()

public void トップ画面からログイン画面に移動してログインしてメイン画面を表示()
public void トップ画面からログイン画面に移動してログインしてメイン画面を表示してユーザー名をクリックしてユーザー画面に移動()

これで合計9個のテストが完成しました。最後に全テストを回してパチパチとやろうとしたら、1個失敗しました。場所はログイン用のユーザー名をSendKeyするところ。しかもこれは再現性が低そうです。とりあえず、SendKeyしていたところを人間的に合わせるためにもClick,Clear,SendKeyに直してこれがオールグリーンになりました。

element.SendKeys(text);

element.Click();
element.Clear();
element.SendKeys(text);

これで当初予定していたリグレッションテストが全部実装しました!

5回目のモブプロ

そして5回目のモブプロは神回になりました。今までのフラストレーションであるコピペプログラムのリファクタリングフェーズです。モブプロと言いつつ、ドライバーはほぼ僕が1人だったのでですが、今回からgitでもう1人とペアプロ的にも進めてみました。スローステップで、一つ一つの細かいアクションをちゃんと声で説明しながら進めていくスタイルでやったのがめちゃめちゃ良かったです(個人的には流々舞だ!と思いました)。

コードがどんどん読みやすく、明確になっていくのが本当に快適で、楽し過ぎて過去最長の5時間以上やってしまいました。テストコードで直接driverを使っているところを無くすという方針が出来たのですが、かなり減ったけれど無くしきれてはいないので、それは次回となりました。また次回はいよいよ4人でプログラムを回す本当の意味でのモブプロも予定しています。

まとめ

このモブプロは隔週で2時間~ぐらいやっていて、若干宿題的にいくつか調べたり実装したりすることはありましたが、基本モブプロの時以外は殆ど時間を取らずに進めていました。このリズムが自分の中ではベストでした。2週間あると、時間を取らないと言いつつ、思考や下調べも出来ます。また、リグレッションテストというあれば便利だけどなくても良いものというのが、テーマとして非常に適していました。実際モブプロの日以外はプロダクトの方をバリバリ進めていました。(ただ4回目の後はしっかり時間を使ってAzure DevOpsの設定をしました。この内容はSeleniumを使ったシステムテストをAzure DevOpsに導入した話に書きました)そしてモブでやるため、知識の獲得や新しい気づきも多く、何より楽しいです。リグレッションテストが終わっても、また別のテスト整備や環境設定まわりなどで続けて行きれば思っています。多謝。 kazuhito_mさん、Hidari0415さん、USHITO Hiroyukiさん。

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

CIでC#(Unity)のコードフォーマット・静的コード解析・スクリプトビルドを行う方法

本稿はKLab Engineer Advent Calendar 2020の6日目の記事です。

はじめに

PullRequest時のCI環境上でC#(Unity)のコードフォーマット・静的コード解析・スクリプトビルドを行う仕組みを作ったので、内容を紹介したいと思います。

使用したツール

ツール名 説明
CleanupCode Command-Line Tool Rider(ReShaper)内蔵のコードフォーマッターをコマンドラインで実行できるようにしたツールで、Riderがなくても単独で実行できるようになっているスタンドアロンツールです
InspectCode Command-Line Tool Rider(ReShaper)内蔵の静的コード解析をコマンドラインで実行できるようにしたツールで、Riderがなくても単独で実行できるようになっているスタンドアロンツールです

上記のツールはRider(ReShaper)内蔵のコードフォーマッターと静的コード解析をコマンドラインで実行できるようにしたライセンスフリーのツールです。
このツールを使うだけであればRider(ReShaper)のライセンスは不要なので、
Rider以外のIDEを使用している開発者はコマンドラインツールだけローカル環境にセットアップして使用する事もできます。
Window,Mac,Linuxに対応しているのとコマンドラインツールなので各種IDEとの連携もやりやすいんじゃないでしょうか。

このコマンドラインツールはRider(ReShaper)のバージョンごとに用意されているので、IDEとコマンドラインツールのバージョンは開発者全員で統一するようにして下さい。

Unityの.slnファイル・.csprojファイルを作成する

コマンドラインツールの実行するには.slnファイルと.csprojファイルが必要です。
CI環境ではgithubからソースコードをcloneして利用する事が多いと思いますが、私のプロジェクトではgithubには.slnと.csprojをコミットしていないためCI環境で作成する必要がありました。

Unityの.slnファイルと.csprojファイルはUnityEditorによって自動生成されますが、作成するためのAPI等は提供されていません。
色々調べたところ、UnityEditorのメニューのAssets → Open C# Projectを実行すると.slnファイルと.csprojファイルが作成される事がわかりましたが、同時にUnityEditorに関連付けられたIDEも起動してしまうためCI環境で実行する事を考えるとIDEの起動は避けたい所です。

そこで、Open C# Projectの内部実装を確認するために、UnityEditorのレポジトリを確認してみたところ、以下のような実装になっていました。
※Unity2018.4のコードです

internal class SyncVS : AssetPostprocessor
{
    // -------省略--------
    [MenuItem("Assets/Open C# Project")]
    static void SyncAndOpenSolution()
    {
        SyncSolution();
        OpenProjectFileUnlessInBatchMode();
    }
    // -------省略--------

実装を見た感じSyncVS.SyncSolution();を実行すれば.slnファイルの作成だけを行ってくれそうですが、SyncVSクラスはinternal classなので別アセンブリからは実行できません。
そこで以下のようにリフレクションを使って呼び出すようにしました。

var syncVs = Type.GetType("UnityEditor.SyncVS,UnityEditor");  
var syncSolution = syncVs.GetMethod("SyncSolution", BindingFlags.Public | BindingFlags.Static);  
syncSolution.Invoke(null, null);

上記の処理をUnityのbatchmodeを使ってコマンドラインから実行する事で、CI環境上で.slnファイル・.csprojファイルを作成する事ができるようになりました。

[Tips]
このようなリフレクションを使ったhackを覚えておくと、たまに役に立つ事があるので覚えておくといいと思います。
注意点としては今回のような非公開の関数を呼び出す場合は、Unityのバージョンが変わった際に実装内容が変わって使えなくなる可能性がある事も頭に入れておいて下さい。

コードフォーマット

コードフォーマットの定義ファイルを作成する

Rider(or ReShaper)でコードフォーマットルールが定義された.sln.DotSettingsファイルを作成します。
作成方法は、Riderの設定画面でコードフォーマット設定を行い、Saveボタンの右の▼ボタンから「Solution "xxxxx" team-shared」ボタンを押す事で、プロジェクトルートに.sln.DotSettingsファイルが作成されます。
Solution
参考:.sln.DotSettingsファイルの作り方

コマンドラインでコードフォーマッターを実行する

コマンドラインツールに含まれるcleanupcode.shスクリプトを使用します。
パラメータには.slnファイルと、
--profileパラメータには.sln.DotSettinsファイルの拡張子(.sln.DotSettins)を除いたファイル名を指定して下さい。
外部ライブラリ等をコードフォーマット対象から外したい場合は--excludeパラメーターで除外する事もできます。

./cleanupcode.sh \
--toolset-path=/Library/Frameworks/Mono.framework/Versions/Current/lib/mono/msbuild/15.0/bin/MSBuild.dll \
--exclude=Assets/LineSDK/**/* \
--profile=Sample \  # .sln.DotSettinsファイルの拡張子を除いたファイル名
Sample.sln

※Windows環境の場合はcleanupcode.exeを使用してください

静的コード解析

コマンドラインで静的コード解析ツールを実行する

コマンドラインツールに含まれるinspectcode.shスクリプトを使用します。
パラメータには.slnファイルと.sln.DotSettingsファイル(拡張子を含める)と解析結果を出力するファイル名を指定して実行して下さい。
外部ライブラリ等を対象から外したい場合は--excludeパラメーターで除外する事もできます。

./inspectcode.sh \
--toolset-path=/Library/Frameworks/Mono.framework/Versions/Current/lib/mono/msbuild/15.0/bin/MSBuild.dll \
--exclude=Assets/LineSDK/**/* \
--profile=Sample.sln.DotSettings \
Sample.sln \
-o=report.xml # 解析結果の内容を出力するファイル

※Windows環境の場合はinspectcode.exeを使用してください

解析結果ページの生成

解析結果はxmlで出力されるためHTMLに整形してCI環境上から確認しやすくしました。
以下のコードではxsltprocコマンドを使って、xmlにxslを適用する事で整形したhtmlを出力しています。

# report.xmlにreport.xslを適用してreport.htmlを出力
xsltproc --output report.html report.xsl report.xml

report.xsl

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:key name="IssueSearch" match="IssueType" use="@Id"/>
  <xsl:template match="Report">
    <html>
      <head>
        <title>Inspect Code Report</title>
        <style type="text/css">
        table {
            font-size: small;
        }
        table, tr, th, td {
            border-collapse: collapse;
            border: 1px solid #000000;
        }
        th {
            background-color: #3333ff;
            color: #ffffff;
        }
        </style>
      </head>
      <body>
        <h1>Inspect Code Report</h1>
        <xsl:apply-templates select="Issues"/>
      </body>
    </html>
  </xsl:template>
  <xsl:template match="Project">
    <h2><xsl:value-of select="@Name"/></h2>
    <table>
      <tr>
        <th>TypeId</th>
        <th>Severity</th>
        <th>File</th>
        <th>Offset</th>
        <th>Line</th>
        <th>Message</th>
        <th>TypeDescription</th>
      </tr>
      <xsl:apply-templates/>
    </table>
  </xsl:template>
  <xsl:template match="Issue">
    <xsl:variable name="typeId" select="@TypeId" />
    <tr>
      <td><xsl:value-of select="@TypeId"/></td>
      <td><xsl:for-each select="key('IssueSearch', $typeId)" ><xsl:value-of select="@Severity"/></xsl:for-each></td>
      <td><xsl:value-of select="@File"/></td>
      <td><xsl:value-of select="@Offset"/></td>
      <td><xsl:value-of select="@Line"/></td>
      <td><xsl:value-of select="@Message"/></td>
      <td><xsl:for-each select="key('IssueSearch', $typeId)" ><xsl:value-of select="@Description"/></xsl:for-each></td>
    </tr>
  </xsl:template>
</xsl:stylesheet>

スクリプトビルド

UnityEditorを使ったスクリプトビルドは遅いので、xbuildコマンドを使ってUnityEditorを使わずにスクリプトビルドを行うようにしました。
UnityEditorを使ったビルドとは完全に同じ挙動にはなりませんが、目的がスクリプトのチェックだけであれば問題になる事は殆どないでしょう。

/Applications/Unity/Unity.app/Contents/MonoBleedingEdge/bin/xbuild Sample.sln

おわりに

同じような事ができるツールはいくつかあると思うので、皆様の環境にあった方法を選択してもらえばいいかと思います。
今回紹介した内容が、その時の一つの選択肢になれば幸いです。

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