vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

UnityでAnimationを作ってみる

はじめに

Unity2017 くらい?から、 Unity でも Maya や Blender などのように 3D アニメーションを作ることができるようになりました。

今回はこれを試してみたいと思います。

環境(いつも書き忘れてますが)

  • Unity 2018.2.8f1

アニメーションを作ってみる

では早速作ってみたいと思います。

Keyframe recording mode を使ったアニメーション作成

まずは Scene に Cube を一つ作成し、それを移動させてみます。

  1. メニューの Window > Animation > Animation から Animation ウインドウを開く
    (ウインドウ下部で、 Curves ではなく Dopesheet が選択されているかも確認する)
  2. Cube を選択した状態で 1.の Add Property ボタンを押し、 Transform > Position の右にある+ボタンを押す
    (anim ファイルの保存を求められるので適当に名前を付けて保存)
  3. Add Property ボタンの上部にある赤丸ボタンを押す
  4. 右側の白い縦線を移動させながら、各時間ごとの Cube の位置を Cube の Inspector > Transform で値を入力して指定する
  5. 完了したらもう一度赤丸ボタンを押して終了する

f:id:mslGt:20180919075056p:plain

再生

作成したアニメーションのプレビューは赤丸ボタンの3つ隣にある再生ボタンでできます。

実際にアニメーションの再生などをコントロールするのは、 anim ファイルと同じ場所に自動で生成される Animator Controller です。

あとは以前 Animator を触った時と同じようにコントロールすれば OK です。

手動でキーを追加する / 削除する

Keyframe recording mode で自動でキーを設定するのではなく、手動でキーを追加したり削除したりしてみます。

上記 1.、2.を実行後、 Add Property ボタンの上に表示された Cube : Position の上で右クリック → Add key でキーを追加、 Delete key でキーを削除できます。

また追加されたキーの値も編集できるので、その気になれば Tween 的な動きもできなくもない気がします。
(素直に DOTween などを使う方が良いとは思いますが)

複数のオブジェクトを動かす

複数のオブジェクトを一度に動かしたい場合。

Hierarchy で複数のオブジェクトを選択し、 Add Property でまとめて。。。と思いきやこれはできません。

anim ファイルが一つの GameObject を対象に作られる(多分)ためです。

ただし、一つの GameObject に子どもとなる GameObject がある場合、親の GameObject に対する anim ファイルを作ると子どもの GameObject にもアニメーションが接敵できるようになります。
(Add Property で子どもの GameObject に対する Position などの設定項目が表示されるようになります)

f:id:mslGt:20180919075039p:plain

ということで、複数の GameObject を同時に動かしたい場合はそれらをまとめて空の GameObject の子どもにしてしまえば OK 、という訳ですね。

おわりに

これでもう Maya も Blender も不要!。。。な訳はありませんが、それらでアニメーションを作る → インポートして動作確認 といったことを繰り返すよりは、可能であれば Unity 内で完結してしまえるのが便利ですね。

Maya や Blender にしても、今回の Unity でのアニメーションにしてもまだ簡単な機能しか使えていないため、どこまでできるのかはまだ不明なのですが。。。

参照

【Unity】【C#】2つのListを見比べて一方にしか存在しないものを検出したい

はじめに

List を検索してガチャガチャやりたい話のメモです。

前提:

  • Id (int型)という共通の要素を持つ2つの List があり、それぞれ searchIds 、 models という名前とする。
  • searchIds の中身は任意で変更できるものとする

やりたいこと:

  • searchIds の中に、 models に含まれない Id を持つものがあれば models にその Id を持つアイテムを追加する
  • models の中に、 searchIds に含まれない Id を持つものがあればそのアイテムを削除する

ベースとなるクラス、変数

searchIds

public 変数とし、 UnityEditor 上から編集 -> ボタンクリックで反映することとします。

public List< int> SearchIds = new List< int> {
    1, 4, 6, 7, 9,
};

models のデータ格納用クラスです。

SampleModel.cs

using UnityEngine;

public class SampleModel: MonoBehaviour {
   public int Id { get; private set; }
   private GameObject model;

   public void Init(int setId, GameObject setModel) {
      Id = setId;
      model = setModel;
   }
   public void DestroyModel() {
      Destroy(model);
      Destroy(gameObject);
   }
}

models

private List models = new List();

ベースのクラス

このクラスの UpdateModels を変更してみて、実行速度や GC Alloc などが変化するかを見てみたいと思います。

ListComparer.cs

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.UI;

public class ListComparer : MonoBehaviour {
    public GameObject BaseCube;
    public Button UpdateButton;

    public List< int> SearchIds = new List< int> {
        1, 4, 6, 7, 9,
    };
    private List< SampleModel> models = new List< SampleModel>();

    private void Start() {
        UpdateButton.onClick.AddListener(() => {
      // 計測開始.
            Profiler.BeginSample("SamplingProfile");
            UpdateModels();
      // 計測終了.
            Profiler.EndSample();

            Debug.Log("count: " + models.Count);
            models.ForEach(m => Debug.Log("id: " + m.Id));
        });
    }

    private void UpdateModels() {
        foreach (int i in SearchIds) {
      // 本来は else で既存データの値を更新するため、FirstOrDefault で SampleModel を取得する.
            SampleModel target = models.FirstOrDefault(s => s.Id == i);
            if(target == null) {
                models.Add(GenerateModel(i, Instantiate(BaseCube)));
            }
        }

        List< int> removeIndexes = new List< int>();
        for (int i = 0; i < models.Count; i++) {
            int index = i;
            if (SearchIds.Any(s => s == models[index].Id) == false) {
                removeIndexes.Add(index);
            }
        }

        removeIndexes.Sort();

        // Remove していくので逆から実行.
        for (int i = removeIndexes.Count - 1; i >= 0; i--) {
            models[removeIndexes[i]].DestroyModel();
            models.RemoveAt(removeIndexes[i]);
        }
    }
    private SampleModel GenerateModel(int id, GameObject model) {
        SampleModel newModel = new GameObject("Model")
            .AddComponent();
        newModel.Init(id, model);
        return newModel;
    }
}

試す

さて、さっそく思いついたものをいくつか試してみたいと思います。

検証 1. 削除してから追加してみる

ListComparer.cs

~省略~
  private void UpdateModels() {

        List< int> removeIndexes = new List< int>();
        for (int i = 0; i < models.Count; i++) {
            int index = i;
            if (SearchIds.Any(s => s == models[index].Id) == false) {
                removeIndexes.Add(index);
            }
        }
        removeIndexes.Sort();

        // Remove していくので逆から実行.
        for (int i = removeIndexes.Count - 1; i >= 0; i--) {
            models[removeIndexes[i]].DestroyModel();
            models.RemoveAt(removeIndexes[i]);
        }

        foreach (int i in SearchIds) {
            SampleModel target = models.FirstOrDefault(s => s.Id == i);
            if(target == null) {
                models.Add(GenerateModel(i, Instantiate(BaseCube)));
            }
        }
    }
  ~省略~

Profiler で確認した結果は後でまとめて載せますが、ベースのコードとほぼ同じでした。

まぁ順番を変えたぐらいでは特に変化がないですね。

検索するのが追加・削除のどちらか一方のみであれば変化があるかもしれません。

検証 2. 要素の中身を破棄するのと List から Remove するのを分ける

削除対象のインデックスを取得するときに、合わせて要素の中身を削除すると何か変わるでしょうか。

ListComparer.cs

~省略~
  private void UpdateModels() {

        List< int> removeIndexes = new List< int>();
        for (int i = 0; i < models.Count; i++) {
            int index = i;
            if (SearchIds.Any(s => s == models[index].Id) == false) {
        models[index].DestroyModel();
                removeIndexes.Add(index);
            }
        }
        removeIndexes.Sort();

        // Remove していくので逆から実行.
        for (int i = removeIndexes.Count - 1; i >= 0; i--) {
            models.RemoveAt(removeIndexes[i]);
        }

        foreach (int i in SearchIds) {
            SampleModel target = models.FirstOrDefault(s => s.Id == i);
            if(target == null) {
                models.Add(GenerateModel(i, Instantiate(BaseCube)));
            }
        }
    }
  ~省略~

他と比べて初回が遅いですね。

削除する方は特に影響ないはずなのですが。。。

検証 3. Linq をなくしてみる

検証1. のコードから、 Linq を外してみるとどうなるでしょうか。

ListComparer.cs

~省略~
  private void UpdateModels() {
    
    List< int> removeIndexes = new List< int>();
        for (int i = 0; i < models.Count; i++) {
            bool found = false;
            for (int j = 0; j < SearchIds.Count; j++) {
                if (SearchIds[j] == models[i].Id)   {
                    found = true;
                    break;
                }
            }

            if (found == false) {
                removeIndexes.Add(i);
            }
        }

        removeIndexes.Sort();
        
        // Remove していくので逆から実行.
        for (int i = removeIndexes.Count - 1; i >= 0; i--) {
            models[removeIndexes[i]].DestroyModel();
            models.RemoveAt(removeIndexes[i]);
        }

        for (int i = 0; i < SearchIds.Count; i++) {
            bool idFound = false;

            for (int j = 0; j < models.Count; j++) {
                if (models[j].Id == SearchIds[i])   {
                    idFound = true;
                    break;
                }
            }
            // 見つかったものにも処理を行う場合は models[SearchIds[i]] で.
            if (idFound == false)   {
                models.Add(GenerateModel(SearchIds[i], Instantiate(BaseCube)));
            }
    }
  }
  ~省略~

流石というかなんというか、 GC Alloc のサイズが小さくなり、速度も速くなりました。

とはいえ変数の量が増えたりコードが長くなってしまうため、毎フレーム実行しまくるとか高パフォーマンスが要求される場面でなければ避けたい、というのが個人的な本音です(ー_ー;)

検証 4. 検証3. を removeIndexes を作らずに実行してみる

検証3. では、 models が Id 順に揃っていないこともあり、インデックスを持つ List(removeIndexes) を作っていました。

これを作らないで直接 Remove していくと何か変わるでしょうか。

自作クラスのソート

まず models を Id でソートする必要があります。

以前試したときのコードを基に SampleModel をソートできるようにします。

SampleModelComparer.cs

using System.Collections.Generic;

// ソート用のクラス.
public class SampleModelComparer: IComparer< SampleModel> {
    public int Compare(SampleModel x, SampleModel y) {
        if (x == null) {
            return y == null ? 0 : 1;
        }
        if (y == null) {
            return -1;
        }
        return x.Id.CompareTo(y.Id);
    }
}

ListComparer.cs

~省略~
  private SampleModelComparer comparer = new SampleModelComparer();
~省略~
  private void UpdateModels() {
    // Remove していくので逆から実行.
        for (int i = models.Count - 1; i >= 0; i--) {
        bool found = false;
            for (int j = 0; j < SearchIds.Count; j++) {
                if (SearchIds[j] == models[i].Id) {
                    found = true;
                    break;
                }
            }
            if (found == false) {
                models[i].DestroyModel();
                models.RemoveAt(i);
            }
        }   
        for (int i = 0; i < SearchIds.Count; i++) {
            bool idFound = false;
    
            for (int j = 0; j < models.Count; j++) {
                if (models[j].Id == SearchIds[i]) {
                    idFound = true;
                    break;
                }
            }
            // 見つかったものにも処理を行う場合は models[SearchIds[i]] で.
            if (idFound == false) {
                models.Add(GenerateModel(SearchIds[i], Instantiate(BaseCube)));
            }
        }
    // 要素を追加した後にソート.
        models.Sort(comparer.Compare);
  }
  ~省略~

わざわざソート用のクラスを作った割には。。。という悲しい結果となりました。

もし removeIndexes で int 型の Id ではなく、重いデータを持つものを格納していたら違った結果になるかもしれません。

結果

f:id:mslGt:20180916073529p:plain

今回試した中では、検証3. の Linq をなくした場合が一番良い結果となりました。

ただ検証3. でも触れた通り、コードが長くなったり変数が多くなったりして、ミスが発生しやすいという懸念もありますので、状況に合わせて選択したいと思います。

また今回の検証では Linq の方が遅い、という結果になりましたが、もう少し GC Alloc の量が少なかったり、速く処理できるコードも書けるかもしれません。

こちらについてはもう少し良いものが見つかれば追加して比較してみたいと思います。

【ASP.NET Core】任意のローカルファイルを読み込み・書き出し

はじめに

ASP.NET Core では Static なファイルはデフォルトで wwwroot に置きます。

が、今回は PC の任意の場所に置いたファイルを Static ファイルとして扱うことができるか、ということを試してみました。

ついでなのでクライアント( Unity )に画像を渡し、それを Texture として読み込んで表示してみることにします。

ローカルファイルにアクセスする

例えば Documents ディレクトリに置いたファイルに、ファイル名を指定してアクセスしてみます。

// Documents をルートディレクトリとして物理ファイルのプロバイダーを生成.
using(PhysicalFileProvider provider = new PhysicalFileProvider(@"C:\Users\example\Documents")) {
  // Documents > memo.txt のファイル情報を取得.
  IFileInfo fileInfo = provider.GetFileInfo(@"memo.txt");
  logger.LogInformation("name: " + fileInfo.Name + " exists: " + fileInfo.Exists + " directory?: " + fileInfo.IsDirectory);
}

実行した結果(ログの内容)は以下の通りです。

name: memo.txt exists: True directory?: False

今度はルートディレクトリだけを指定して、その下にあるディレクトリ、ファイルの情報を取得してみます。

なお該当のディレクトリには下記のようなファイル、ディレクトリがあるものとします。

ImageSample ← ルートディレクトリとして指定
  Lsample1.jpg
  Ldir1
    Ldir2
      Lsample2.jpg
    Lsample3.jpg

// ImageSample をルートディレクトリとして物理ファイルのプロバイダーを生成.
using (PhysicalFileProvider provider = new PhysicalFileProvider(@"C:\Users\example\Pictures\ImageSample")) {
  // サブディレクトリを指定する場合は GetDirectoryContents の引数で指定.
  IDirectoryContents contents = provider.GetDirectoryContents(string.Empty);
  foreach (IFileInfo fileInfo in contents){
      logger.LogInformation("name: " + fileInfo.Name + " exists: " + fileInfo.Exists + " directory? " + fileInfo.IsDirectory);
  }
}

実行した結果(ログの内容)は以下の通りです。

name: sample1.jpg exists: True directory?: False
name: dir1 exists: True directory?: True

対象のディレクトリだけが対象となっていて、それより下の階層の情報は取れていません。

全部取得してみる

このノリで、ルートディレクトリ以下のディレクトリ、ファイル情報をまとめて取ってみることにします。

またファイルは、画像ファイルのみをターゲットとしてみます。

まず情報を格納する構造体を用意します。

ImageFile.cs

public struct ImageFile {
    public string DirectoryPath { get; }
    public string FileName { get; }

    public ImageFile(string setDirectoryPath, string setFileName) {
        DirectoryPath = setDirectoryPath;
        FileName = setFileName;
    }
}

別にクラスでもいいのですが、 More Effective C# に従って、データを格納するものは構造体にすることにします。

public List< ImageFile> Load() {
  List< ImageFile> loadedFiles = new List< ImageFile>();

  using (PhysicalFileProvider provider = new PhysicalFileProvider(@"C:\Users\example\Pictures\ImageSample")) {
      loadedFiles.AddRange(
          Load(provider, @"\"));
  }
  // 取得した情報をログに出力してみる.
  foreach (ImageFile f in loadedFiles) {
      logger.LogInformation("directory: " + f.DirectoryPath + " file: " + f.FileName);
  }
  return loadedFiles;
}
private static List< ImageFile> Load(PhysicalFileProvider provider, string parentDirectory) {
    List< ImageFile> loadedFiles = new List< ImageFile>();

    foreach (IFileInfo fileInfo in provider.GetDirectoryContents(parentDirectory)) {
        if (fileInfo.IsDirectory) {
            // ディレクトリならその下にあるファイルを取得する.
            loadedFiles.AddRange(
                Load(provider, parentDirectory + fileInfo.Name + @"\"));
        }
        // IFileInfo では拡張子を取れないようなので、正規表現でファイルを選別.
        else if(Regex.IsMatch(fileInfo.Name, "(jpg|png)$")) {
            loadedFiles.Add(
                new ImageFile(parentDirectory, fileInfo.Name));
        }
    }
    return loadedFiles;
}

ログの出力結果は以下の通りです。

directory: \dir1\dir2\ file: sample3.jpg
directory: \dir1\ file: sample2.jpg
directory: \ file: sample1.jpg

結果の通り、階層が深い方から順に並ぶことになるので、 DirectoryPath でソートした方が良いかもしれません。

取得した情報を JSON として扱う

取得した List< ImageFiles> ですが、クライアント側に渡せるよう、 JSON として扱えるようにしたいと思います。

例えば GET リクエストでアクセスしたときに、 JSON で返す方法は下記のようにできます。

HomeController.cs

namespace WebApplication1.Controllers {
  public class HomeController : Controller {
    ~省略~
    [Route("/getfileinfo")]
    [Produces("application/json")]
    public List< ImageFile> Index() {
      // C:\Users\example\Pictures\ImageSample のデータを取得して返す.
      return Load();
    }
  }
}

ただこの場合、アクセスするたびにデータを作ることになるため、効率が悪いです。

そのため、データを読み込んだ後 JSON ファイルとして一旦保存しておき、アクセスされたときはそれを渡すようにしてみます。

JSON に変換する

DataContractJsonSerializer を使うことで JSON に変換することができます。

まず DataContractJsonSerializer で扱うことができるよう、 ImageFile を変更します。

ImageFile.cs

using System.Runtime.Serialization;

namespace WebApplication1.FileAccessers {
    [DataContract]
    public struct ImageFile {
        [DataMember]
        public string DirectoryPath { get; private set; }
        [DataMember]
        public string FileName { get; private set; }

        public ImageFile(string setDirectoryPath, string setFileName) {
            DirectoryPath = setDirectoryPath;
            FileName = setFileName;
        }
    }
}

注意が必要な点は2つです。

  1. クラスに DataContract 、 変数(プロパティ) に DataMember のアトリビュートをつける必要があります。
  2. 各プロパティに set が必要で、 get だけにしているとエラーになります( private でも OK )。

で、これを使って JSON に変換してみます。

~省略~
using System.Runtime.Serialization.Json;
using System.Text;
~省略~
    private byte[] Serialize(List< ImageFile> originalData) {

        using (MemoryStream ms = new MemoryStream()) {
            // List< ImageFile> を変換する場合は DataContractJsonSerializer の型も List< ImageFile> とする.
            DataContractJsonSerializer serializer = new DataContractJsonSerializer(typeof(List< ImageFile>));

            serializer.WriteObject(ms, originalData);
            return ms.ToArray();
        }
    }
  }
}

ファイルを書き出すときのことを踏まえて byte[] で返していますが、 string でほしい場合は Encoding.UTF8.GetString(json, 0, json.Length) のように変換すれば OK です。

ファイルの書き出し

ではこの JSON をファイルとして書き出します。

ここでは FileStream を使います。

// JSON 変換した byte[] データ.
byte[] result = Serialize(loadedFiles);

using (FileStream stream = new FileStream(@"C:\Users\example\Pictures\ImageSample\createdjson.json", FileMode.Create)) {
    stream.Write(result, 0, result.Length);
}

特に ASP.NET Core でなくても変わらないですね。

ただ今回は同期的に書き込んでいますが、 WriteAsync にした方がよいかもしれません。

ローカルのディレクトリをStaticFilesに追加する

ここまで扱ってきたファイルを、外からも見えるようにしてみたいと思います。

その方法として、ローカルのディレクトリをStaticFilesに追加する、というものがあります。

Startup.cs

~省略~
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
  ~省略~
  // wwwroot を Static ファイルのルートとして設定する.
  app.UseStaticFiles();

  // ローカルの Pictures にある ImageSample をStatic ファイルのディレクトリとして追加.
  app.UseStaticFiles(new StaticFileOptions {
      FileProvider = new PhysicalFileProvider(
          Path.Combine(@"C:\Users\example\Pictures", "ImageSample")),
      RequestPath = "/img"
  });
  ~省略~
}
~省略~

こうすることで、例えば localhost:5XXX/img/sample1.jpg にアクセスすると C:\Users\example\Pictures\ImageSample\sample1.jpg が表示されるようになります。

アプリの発行後、外部からファイルを差し替えたい、といった場合に利用できそうです。

おわりに

案外簡単にアクセス可能にできるものなのだなぁ。というのが正直な感想です。

場合によってはルートディレクトリ内の特定のディレクトリ/ファイルだけアクセスさせないなどの措置も必要になりそうですね。

こちらについては別途調べて書き残そうかと思っています。

参照

ASP.NET Core でのログ出力( ILogger + NLog )

はじめに

今回はログ出力を追ってみることにします。

.NET でログを出力するライブラリはたくさんあるようですが、今回は組み込みである ILogger を使ってみます。

内容としては ILogger の使い方、特にファイルに内容を出力する方法が中心になる予定です。

httpsについて

いきなり違う話から入るのですが、 ASP.NET Core2.1 でプロジェクトを作ると https://localhost:5XXXhttps でページを開くようになります。
(Visual Studio でプロジェクトを作る場合、プロジェクト生成時にチェックを外すと http で開くよう設定を変更できます)

それは良いのですが、 Firefox では証明書が不正となりページが開けません。

ググってみたところ、 https://localhost:(ポート番号) を例外として設定する以外に開く方法はなさそうです。

localhost なんだし多めに見て、という気持ちはありますが、まぁ仕方ありませんね。

実際のリリース時には正しい証明書などを用意することとして、開発中は例外設定してしまいたいと思います。

ログ出力してみる

気を取り直して、とりあえずログを出力してみます。

例えば HomeController.cs からログを出力したい場合、 HomeController クラスのコンストラクタに ILogger< HomeController> を渡します。

HomeController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace WebApplication1.Controllers {
    public class HomeController : Controller {
        private readonly ILogger< HomeController> logger;

        // DI でインスタンスをセット.
        public HomeController(ILogger< HomeController> logger) {
            this.logger = logger;
        }
        public string Index() {
            // INFO レベルでログを出力.
            logger.Log(LogLevel.Information, "Hello work!");

            return "hello";
        }
        ~省略~
    }
}
  • ILogger< TCategoryName> の型は、ログの「カテゴリ」を指定するためのもので、
    文字列で指定することもできるが、ログを書き込むクラスを指定する必要があるため型を渡す方が簡単にできます。
  • 「カテゴリ」が何を指すのかは正確にはわからなかったのですが、そのログがどのクラスから出力されたのかを判別するもの、
    というくらいのものであるようです(そのためクラスの完全修飾名が必要)。

Service への追加?

DI ということで、以前と同じように Startup クラスで Service に追加…

する必要はありません。

Program クラスで下記を使用している場合、デフォルトのログ出力設定が CreateDefaultBuilder の中で行われます。

Program.cs

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;

namespace WebApplication1 {
    public class Program {
        public static void Main(string[] args) {
            CreateWebHostBuilder(args).Build().Run();
        }
        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup< Startup>();
    }
}

WebHost.cs

// Decompiled with JetBrains decompiler
// Type: Microsoft.AspNetCore.WebHost
~省略~
public static IWebHostBuilder CreateDefaultBuilder(string[] args) {
    IWebHostBuilder hostBuilder = new WebHostBuilder()
        ~省略~
      .ConfigureLogging((Action) ((hostingContext, logging) =>
      {
        logging.AddConfiguration((IConfiguration) hostingContext.Configuration.GetSection("Logging"));
        logging.AddConsole();
        logging.AddDebug();
      }))
    ~省略~
}

ILoggerFactory

なおググっている中で、 Startup の Configure の引数として ILoggerFactory を渡しているものもありました。

Startup.cs

~省略~
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) {
  ~省略~  
}

で、この ILoggerFactory でも AddConsole や AddDebug を実行することはできるようでした。

両者の使い分けに悩むところですが、少なくとも Microsoft Doscs を見る限りだと前者を使う方が良さそうです。
(後者はダメとまでは書かれていませんが、特に触れられていないため)

docs.microsoft.com

NLogを試してみる

Console に出力する分には問題がないのですが、ファイル出力したい今回のような場合、 Provider を自分で作る(またはサードパーティーのものを使う)必要があるようです。

今回は NLog を使ってみることにしました。

NLog の Wiki を基に進めていきます。

github.com

インストール

NuGet で NLog.Web.AspNetCore をインストールします
(いくつか種類がありますが、 ASP.NET Core 用のものを選びます。 ver.4.6 でした)。

ファイルを追加する

1.NLog の設定ファイル( nlog.config )をプロジェクト直下に作ります。 2.nlog.config のプロパティで、 詳細設定 > 出力ディレクトリにコピー の値を「常にコピーする」に変更します。

f:id:mslGt:20180906043538j:plain

※画像は Visual Studio ですが、 Rider の場合はファイル上で右クリック > Properties > Copy to output directory から設定できます。

3.Program.cs で NLog を有効にします。

Program.cs

using System;
using System.IO;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using NLog.Web;

namespace WebApplication1 {
    public class Program {
        public static void Main(string[] args) {
            // nlog.configの読み込み.
            var logger = NLog.Web.NLogBuilder.ConfigureNLog("nlog.config").GetCurrentClassLogger();
            try {
                CreateWebHostBuilder(args).Build().Run();
            }
            catch (Exception ex) {
                logger.Error(ex, "Stopped program because of exception");
                throw;
            }
            finally {
                NLog.LogManager.Shutdown();
            }
        }

        public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
            new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup< Startup>()
                .ConfigureLogging((hostingContext, logging) => {
                    // NLog 以外で設定された Provider の無効化.
                    logging.ClearProviders();
                    // 最小ログレベルの設定.
                    logging.SetMinimumLevel(LogLevel.Trace);
                })
                // NLog を有効にする.
                .UseNLog();
        }
    }
}

nlog.configを読む

Wiki の「2.Create a nlog.config file.」を見てみると、結構複雑な感じがしてきます。

とはいえ個別にコメントを書いてくれているのと、 Wiki に nlog.config の個別ページも用意されているため、追いかけるのは容易そうです。

github.com

  • nlog: nlog.config 全体の設定、 internal-nlog.txt の出力設定を行います。
  • extensions: nlog.config の中で使われる、「${ }」のような表現方法を有効にするための機能を有効にします。
  • targets: ログの出力タイプ(ファイルとかコンソールとか)、名前( rules でこの値を使ってターゲットを指定します)、ファイルの場合はその出力先などを指定します。
  • rules: targets の中で設定したそれぞれのターゲットに、どのレベル( Info, Warning など)のログを出力するかなどのルールを指定します。

出力されるファイルは下記の三つです。

  • internal-nlog.txt: NLog の内部処理に関するログが出力されます。
  • nlog-all-${shortdate}.log: Trace レベルのログを出力します。
  • nlog-own-${shortdate}.log: Microsoft.* 名前空間下の Info レベルのログを除く、Trace レベルのログを出力します。

ログレベルについては下記参照、ということなのですが、Trace レベルが「メソッド X の始め、メソッド X の終わり、etc.」のように書かれていて、あまりよく分かりませんでした(- _ -;)

github.com

出力されたものを見ると、呼ばれたメソッドの情報が出力される、ということのようでした。

また rules で設定されるルールで、 nlog-all-${shortdate}.log と nlog-own-${shortdate}.log のルールは同じように見えるのですが、間に final="true" を持ったルールが追加されることで、Microsoft.* 名前空間下の Info レベルのログが除かれる結果となっています。

nlog.config

~省略~
  < rules>
    < !--All logs, including from Microsoft-->
    < logger name="*" minlevel="Trace" writeTo="allfile" />

    < !--Skip non-critical Microsoft logs and so log only own logs-->
    < logger name="Microsoft.*" maxLevel="Info" final="true" /> < !-- BlackHole without writeTo -->
    < logger name="*" minlevel="Trace" writeTo="ownFile-web" />
  < /rules>
  ~省略~

nlog.configを書く

ではこのサンプルを基に、自分でも nlog.config を書いてみようと思います。

nlog.config

< ?xml version="1.0" encoding="utf-8" ?>
< !-- NLog 内部のログはいったん出力しないこととする -->
< nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      autoReload="true">

  < extensions>
    < add assembly="NLog.Web.AspNetCore"/>
  < /extensions>

  < targets>
    < !-- コンソールに出力  -->
    < target xsi:type="Console" name="alloutput"
            layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}" />

    < target xsi:type="File" name="ownFile-web" fileName="C:\temp\logs\${date:format=yyyy}\${date:format=MM}\nlog-own-${shortdate}.log"
            layout="${longdate}|${event-properties:item=EventId_Id}|${uppercase:${level}}|${logger}|${message} ${exception:format=tostring}|url: ${aspnet-request-url}|action: ${aspnet-mvc-action}" />

    < target xsi:type="File" name="ownFile-access" fileName="C:\temp\accessLogs\${date:format=yyyy}\${date:format=MM}\nlog-access-${shortdate}.log"
            layout="${longdate},${message} ${exception:format=tostring}" />
  < /targets>
  < rules>
    < !--コンソールには Trace レベル以上のログすべてを出力-->
    < logger name="*" minlevel="Trace" writeTo="alloutput" />
    < !--Microsoft.* のクラスの Info レベル以下のログはスキップ-->
    < logger name="Microsoft.*" maxLevel="Info" final="true" />
    < logger name="*" minlevel="Info" writeTo="ownFile-web" />
    < !-- HomeController に対するアクセスログ( Info レベル)のみ記録 -->
    < logger name="WebApplication1.Controllers.HomeController" minLevel="Info" maxLevel="Info" writeTo="ownFile-access" />
  < /rules>
< /nlog>

これで例えば今日( 2018.09.06 )実行した場合、下記の場所にファイルが出力されます。

  • C:\temp\accessLogs\2018\09: HomeController にアクセスしたときのログ
  • C:\temp\logs\2018\09: Microsoft.* 以下のクラスを除くアプリケーション全体での、Info レベル以上のログ

※ NLog 内部処理のログを出力しない設定にしていますが、本来出すべきだとは思います。

おわりに

一応これでログ出力はできるようになりましたが、まだいくつか課題はあります。

  1. ログがどのように出力されるかわかっていない → Program.cs に追加した NLog 関連のコードからたどっていくのが良さそう?
  2. ログを出力するレベルや、フォーマットなどログ設計について学習が必要
  3. Visual Studio で nlog.config を編集しているときに、設定が正しく反映されないことがあった
  4. Rider で nlog.config を開くと xsi:type="Console" などでエラーとして表示(ビルドや実行には問題なし)

3.については Program.cs でログ出力すると直ったり、4.は nlog.config に限らず IntelliJAndroid Studio などでも見かけた現象だったりするので、改善の余地もありそうです。

これらについてはおいおい解決していきたいと思います。

また今回は触れませんでしたが、 NLog の出力先として DB やメールなども利用可能(らしい)ですので、そちらも試してみたいと思います。

参照

https

ILogger

NLog

Rider2018.2で日本語コメントがTypo扱いされる

はじめに

Rider2018.2 にアップデートしたところ、日本語がほぼ全て Typo 扱いされるようになりました。

f:id:mslGt:20180830071802j:plain

この辺りの話を見ていると、スペルチェックの機能が変更されたのが原因のようです。

もんりぃ先生👨‍🏫マンガでわかるUnity連載中! on Twitter: "Rider 2018.2 EAP は日本語コメントが軒並み typo 扱いになる…。まぁ、「英語で書けやー!」って言われたら「あ、はい。ごめんなさい。」って感じだけどw"

blog.jetbrains.com

今後の更新で英語以外でも正しくスペルチェックが効くようになるんじゃないかな?と期待はしているのですが、現状の対処法としては

  1. Typo 扱いされても気にしない
  2. コメントを片っ端から辞書登録
  3. コメントに対するスペルチェックを無効にする

があると思っています。

1.が最強ではありますが、やっぱり気になる。2.は現実的ではないし。。。ということで、3.をやってみたのでそのメモです。

設定を変更する

スペルチェックの項目というと、Setting > Editor > Spelling > ReSpeller が引っかかるのですが、今回の目的には合致しないようでした。

コメントに対するスペルチェックを無効にするには、 Settings > Inspection Settings > Inspection Severity > C# > Spelling Issues > Typo in comment のチェックを外します。

f:id:mslGt:20180830073040j:plain

あとは Save ボタンを押せば OK です。

せっかくの機能を止めてしまう、というのは心苦しくはあるので、もう少し良い解決方法があればとは思うのですが。

参照

ASP.NET Coreのプロジェクトを発行(Publish)してみる

はじめに

これまで ASP.NET Core を実行するとき、Visual Studio や Rider 上で実行していました。

が、実際にはビルドしてアプリケーション単体で実行する必要があるため、これを試してみることにしました。

今回は組み込み Web サーバーである Kestrel だけを使う方法を試します。

Webサーバーについて

まず勘違いをしていたのですが、何となく ASP.NET Core で作った Web アプリケーションは、IIS などの Web サーバー上でないと動かないものだと思っていました。

実際のところは ASP.NET Core > Kestrel という構成で元々動作するものの、 Kestrel では機能が不足しているために ASP.NET Core > Kestrel > IIS などの Web サーバー(リバースプロキシ)という構成をとるものが多い、ということのようです。

組み込みの Web サーバーを使うのは SpringBoot でも同じですね。

バースプロキシを使う必要性については、下記が分かりやすいと感じました。

docs.microsoft.com

なお Kestrel を使う他にも方法があるようですが、ここでは Kestrel を使用することとします。

IIS と組み合わせる方法については次回以降トライする予定です。

話は戻って、この辺りの区別がついていなかったために後述のプロファイル作成などで結構迷ってしまうことになりました。

プロジェクトを発行する

では張り切ってプロジェクトを発行してみましょう。

Visual Studio の場合、 ビルド > WebApplication1 (プロジェクト名)の発行 から行うことができます。

プロファイルの作成

初めに発行のためのプロファイルを作成します。

f:id:mslGt:20180821214623j:plain

(最初はこの画面じゃなかった気がするのですが) この画面が表示されたら、 新しいプロファイル > フォルダー > 発行 で、 ASP.NET Core > Kestrel の構成で動作させるためのプロファイルが作成できます。

元の画面に戻ったら、 発行 ボタンを押せば(デフォルトでは bin\Release\netcoreapp2.0\publish に)実行のためのファイルが出力されます。

これに成功すれば、あとはターミナルなどで bin\Release\netcoreapp2.0\publish に移動し、下記のコマンドで実行すれば OK です。

dotnet WebApplication1.dll

外部アクセスを許可する

これまですっかり忘れていたのですが、デフォルトの状態だと実行中の ASP.NET Core のサイトに、同一ネットワークに接続している他の端末からアクセスすることはできません。

理由は二つあります。

  1. http://localhost:5XXX」のような URL ではアクセスできても、「http://IPアドレス:5XXX」ではアクセスできない
  2. Sinatra などと同様、デフォルトでは外部からのアクセスを制限している

これを何とかしてみます。

1.URL を変更する

Kestrel 単体で動かす場合

IIS Express などを使わず Kestrel 単体で動かす場合、プロジェクトフォルダ > Properties にある launchSettings.json を変更すれば OK です。

launchSettings.json

{
  ~省略~
  "profiles": {
    ~省略~
    },
    "WebApplication1": {
      "commandName": "Project",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "applicationUrl": "http://変更したいIPアドレス:ポート番号/"
    }
  }
}

IIS Express を使う場合

IIS Express を使う場合も launchSettings.json を変更するのですが、それだけではエラーになってしまいました。

launchSettings.json

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://変更したいIPアドレス:ポート番号/",
      "sslPort": 0
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    ~省略~
  }
}

ググってみると、 IIS Express 側の設定も変更する必要があり、 applicationhost.config を変更すればできる、とのこと。

ということで、ドキュメントフォルダ > IISExpress > config 以下にある applicationhost.config を変更したのですが、特に何も変わらないorz

さらにググってみると、どうやら別のところの applicationhost.config を見ているらしいと。

結局 ASP.NET Core ver.2.0 では プロジェクトフォルダ > .vs > config に置かれていました。

applicationhost.config

< site name="WebApplication1" id="2">
        < application path="/" applicationPool="WebApplication2 AppPool">
          < virtualDirectory path="/" physicalPath="プロジェクトフォルダまでのパス\WebApplication1\WebApplication1" />
        < /application>
        < bindings>
                    < binding protocol="http" bindingInformation="*:5XXX:変更したいIPアドレス" />
                    < binding protocol="http" bindingInformation="*:5XXX:localhost" />
        < /bindings>
      < /site>
  • 5XXX はポート番号です。念のため。

そしてやっぱりエラー

ええ、これでいけるぜやったぜ!と思いながら実行しました。

やっぱりエラーになるとorz

どうやら IIS Express で localhost 以外のアドレスを指定したい場合、Visual Studio を管理者として実行する必要があるようです。

とりあえずこれで localhost 以外のアドレスを扱えるようになりました。

あとは ACL(Access Control List) やファイヤーウォールの設定を行うことで外部からもアクセス可能になります。

IIS Express のWebサイトに他のホストからアクセスできるようにする - ASP.NETアプリケーションのデバッグ時に他のホストからのアクセスをする

2.外部からのアクセスを許可する

外部からのアクセスを許可するには、 Program.cs で、 Listen する URL を指定する必要がある、ということです。

Program.cs

using System.IO;
using Microsoft.AspNetCore.Hosting;

namespace WebApplication1 {
    public class Program {
        public static void Main(string[] args) {
            BuildWebHost(args).Run();
        }
        public static IWebHost BuildWebHost(string[] args) =>
            new WebHostBuilder()
                .UseKestrel()
                .UseContentRoot(Directory.GetCurrentDirectory())
                .UseIISIntegration()
                .UseStartup()
                // すべてのアドレスを許可.
                .UseUrls("http://0.0.0.0:0")
                .Build();
    }
}

これで OK 。。。のはずだったのですが。

UseUrls が効かない

UseUrls を使っている・使っていないにかかわらず外部からアクセスできてしまいましたorz

ただ、例えば http://0.0.0.0:0http://1.2.3.4:5 のように変更するとエラーになったため、デフォルトでは制限なし、明示的に指定した場合のみ制限がかかる、ということかもしれません。

Empty でプロジェクトを作成したときに使用されている、 WebHost.CreateDefaultBuilder を見ても UseUrls は使用されていないようでした。

というわけで、結果としては 1.URL を変更する だけを設定すれば OK のようです。

なお今回は UseUrls を使う場合もハードコーディングしていましたが、JSON を使って環境によって切り替え、といったこともできるようです。

長くなってきたので今回は特にこれ以上触れません。

おわりに

localhost の代わりに IP アドレスを使うとかすぐできるでしょ。と高を括っていた割に時間がかかってしまいました(;゚Д゚)

次回は IIS を使ったリバースプロキシに挑戦してみる予定です。

多分。

※2018.08.24 追記

今回出力したファイルを使って、下記の方法で登録したら問題なく実行できました。

qiita.com

ということでやっぱり次回は Model についてかなぁ。

参照

IIS Express

IIS

【ASP.NET Core】Postで受け取った値をWebSocketで送信する

はじめに

今回はこの二つをつなげてみることにします。

流れとしては

  1. WebSocket で接続
  2. Post リクエストを送信する
  3. 2.で受け取った値を 1.に送信する

本当は WebSocket の送信、またはプッシュ通知を使うべきところのような気もしますが、とりあえず今回はこれでいくことにします。

WebSocketのコントローラーをインジェクトできるようにする

以前 WebSocket の通信処理は WebSocketController というクラスで行っており、それは Startup から呼び出していました。

今回はルーティングを行う Controller クラスからも( Post リクエストで値を送信する際)アクセスしたいので、 DI を使ってインジェクトできるようにします。

IWebsocketController.cs

using Microsoft.AspNetCore.Builder;
using WebApplication1.Domains;

namespace WebApplication1.Controllers {
    public interface IWebsocketController {
        void Map(IApplicationBuilder app);
        void SendMessage(Message message);
    }
}

この interface を WebSocketController (クラス)で継承します。

ここで注意が必要なのは、 DI でインジェクトするために WebSocketController のコンストラクタを public にする必要があり、インジェクトする interface も public にする必要がある、ということです。

で、以前試した方法と同じように Startup クラスからインジェクトします。

Startup.cs

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using WebApplication1.Controllers;

namespace WebApplication1 {
    public class Startup {
        public IConfiguration Configuration { get; }

        public Startup(IConfiguration configuration) {
            Configuration = configuration;
        }
        public void ConfigureServices(IServiceCollection services) {
            services.AddSingleton< IWebsocketController, WebsocketController>();
            services.AddMvc();
        }
        ~省略~
    }
}

そしてインジェクトされる Controller クラスです。

HomeController.cs

using System;
using Microsoft.AspNetCore.Mvc;
using WebApplication1.Domains;

namespace WebApplication1.Controllers {
    public class HomeController : Controller {
        private readonly IWebsocketController websocket;

        public HomeController(IWebsocketController setWebsocket) {
            websocket = setWebsocket;
        }
        
        ~省略~

        [Route("/PostSample")]
        [HttpPost]
        public void PostSample([FromForm]Message message) {
            // WebSocket で接続しているデバイスに Post リクエスト受け取った値を送信する.
            websocket.SendMessage(message);
        }      
    }
}

接続したWebSocketの情報をキャッシュする

前回トライしたとき、 WebSocket の接続、情報の送受信は下記のようにしていました。

WebsocketController.cs

using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;

namespace WebApplication1.Controllers {
    public class WebsocketController {
        public void Map(IApplicationBuilder app) {
            app.UseWebSockets();
            app.Use(async (context, next) => {
                if (context.WebSockets.IsWebSocketRequest) {
                    // HTTPリクエストの内容が WebSocket のものであればAcceptし、メッセージの受信・送信を行うメソッドに渡す.
                    WebSocket webSocket = await context.WebSockets.AcceptWebSocketAsync();
                    await Echo(context, webSocket);
                }
                else {
                    // HTTPリクエストの内容が WebSocket のものでなければエラー.
                    context.Response.StatusCode = 400;
                }
            });
        }

        // 引数のHttpContextは今回未使用. やりたいことがこれだけなら不要かも.
        private async Task Echo(HttpContext context, WebSocket webSocket) {

            // 送受信するデータ(buffer)のサイズ指定.
            byte[] buffer = new byte[1024 * 4];

            WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);

            // 接続がCloseされるまでメッセージの送受信を繰り返し.
            while (result.CloseStatus.HasValue == false) {
                await webSocket.SendAsync(new ArraySegment< byte>(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, CancellationToken.None);

                result = await webSocket.ReceiveAsync(new ArraySegment< byte>(buffer), CancellationToken.None);
            }
            // Whileを抜けたらClose処理.
            // 接続が Close されると、result.CloseStatusに「NormalClosure」のような値が入ってくる.
            await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
        }
    }
}

肝心の送受信のところは while でループしていて、 Post リクエストを受けたら送信、とするのは面倒そうです。

ということで、ここを変更してみます。

誰が WebSocket の通信に関する情報を持っているか

誰が WebSocket で通信しているクライアントの情報を持っているか、というと、 WebSocket クラスです。

そのままですね。

これを保持しておけば、ループ以外の場所からも送信ができるハズ。

一人だけ接続する場合

WebSocketController.cs

using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using WebApplication1.Domains;

namespace WebApplication1.Controllers {
    public class WebsocketController: IWebsocketController {
        private WebSocket webSocket;

        // DI によるインジェクト.
        public void Map(IApplicationBuilder app) {
            app.UseWebSockets();
            app.Use(async (context, next) => {
                if (context.WebSockets.IsWebSocketRequest) {
                    webSocket = await context.WebSockets.AcceptWebSocketAsync();
                    await Echo(context);
                }
                else {
                    context.Response.StatusCode = 400;
                }
            });
        }

        public async void SendMessage(Message message) {
            if (webSocket == null) {
                return;
            }
            byte[] additionalBytes = System.Text.Encoding.UTF8.GetBytes(message.BodyText);
            await webSocket.SendAsync(new ArraySegment< byte>(additionalBytes, 0, additionalBytes.Length),
                WebSocketMessageType.Text, true, CancellationToken.None);
        }
        private async Task Echo(HttpContext context) {
            byte[] buffer = new byte[1024 * 4];

            WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment< byte>(buffer), CancellationToken.None);

            while (result.CloseStatus.HasValue == false) {            
                result = await webSocket.ReceiveAsync(new ArraySegment< byte>(buffer), CancellationToken.None);
            }
            await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
            webSocket.Dispose();
        }
    }
}

とりあえず一人だけ接続した体で書いてみました。

Echo で ReceiveAsync を残しているのは、接続が Close されたかを調べたいためです。

もちろん WebSocket で接続しているクライアント側からも送信したい場合は SendAsync も必要です。

ローカル変数がメンバー変数になっただけで、特に大きな違いはないかと思います。
送信時、 WebSocket で接続されていない、または接続を閉じた(+ Dispose) 場合に処理をスキップする辺りは多少注意がいるのかもしれません。

複数人で接続した場合

基本的には WebSocket をリスト化するだけです。

WebsocketController.cs

using System;
using System.Collections.Generic;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using WebApplication1.Domains;

namespace WebApplication1.Controllers {
    public class WebsocketController: IWebsocketController {
        private List webSockets = new List< WebSocket>();
        public void Map(IApplicationBuilder app) {
            app.UseWebSockets();
            app.Use(async (context, next) => {
                if (context.WebSockets.IsWebSocketRequest) {
                    WebSocket newWebSocket = await context.WebSockets.AcceptWebSocketAsync();
                    webSockets.Add(newWebSocket);
                    
                    await Echo(context, newWebSocket);
                }
                else {
                    context.Response.StatusCode = 400;
                }
            });
        }
        public async void SendMessage(Message message) {
            foreach (WebSocket w in webSockets) {
                if (w.State == WebSocketState.Closed) {
                    continue;
                }
                byte[] additionalBytes = System.Text.Encoding.UTF8.GetBytes(message.BodyText);
                await w.SendAsync(new ArraySegment< byte>(additionalBytes, 0, additionalBytes.Length),
                    WebSocketMessageType.Text, true, CancellationToken.None);
            }
        }
        private async Task Echo(HttpContext context, WebSocket webSocket) {
            byte[] buffer = new byte[1024 * 4];

            WebSocketReceiveResult result = await webSocket.ReceiveAsync(new ArraySegment< byte>(buffer), CancellationToken.None);
            
            while (result.CloseStatus.HasValue == false) {
                result = await webSocket.ReceiveAsync(new ArraySegment< byte>(buffer), CancellationToken.None);
            }
            await webSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
            webSocket.Dispose();

            // 接続を切った後Listから削除するので、対象のインデックスはWebSocketを使って動的に取る.
            int index = webSockets.IndexOf(webSocket);
            if(index >= 0 && index < webSockets.Count) {
                webSockets.RemoveAt(index);
            }
        }
    }
}

コメントにも書きましたが、先ほど無くしていた Echo の引数の WebSocket ですが、接続が切れた WebSocket は List から外していくため RemoveAt を使うためには動的にインデックスを取る必要があります。

このインデックスを IndexOf で取得するためにもう一度 WebSocket を渡すようにしています。

以前 Spring boot で試したときは、 WebSocketSession が Id を持っていたためそれで判断していましたが、今回そのようなデータが取れないようだったため IndexOf を使っています。

で、この IndexOf 、どうやって WebSocket を比較しているのかを知りたかったのですが、これを書いている 2018.08.08 時点ではうまく情報を見つけることができませんでした。

これについては追加で情報が見つけられれば追記します。