ASP.NET Core MVCのプロジェクトの.NET Coreを2.1 RCにアップグレードした

 このブログでバックエンドに使っている.NET Coreのバージョン2.1がRelease Candidateまで来た。ここらでアップグレード準備をしておくことにした。
 このブログのバックエンドになっているプロジェクトは、Dockerコンテナに入ったASP.NET Core MVC使用のアプリで、TravisCIにてコンテナのビルドとテストまで行われる構成になっている。


 やってみると、参考になるドキュメントさえあれば一時間足らずで終わるものだった。アップグレードのための変更点は、プレビューの時点のもので抑えておくことで問題なかった。
ASP.NET Core 2.1.0-preview2 now available


 具体的な変更としてまず最初にコミットへのリンクを置いておく。
https://github.com/hMatoba/tetsujin/commit/3bc013f237007c64008dc41adb81f14d1384792c


 ここから行った変更について書いていく。まず開発環境に.NET Core 2.1(RC)のSDKをインストールする。
https://www.microsoft.com/net/download/all

 つづいてVisual Studioで、プロジェクトエクスプローラで対象プロジェクトを右クリックして、プロパティからターゲットフレームワークを.NET Core 2.1に変更する。



 同じくプロジェクトエクスプローラで対象プロジェクトを右クリックして、「NuGetパッケージの管理」を開いて、プレリリースを含めるにチェックを入れて導入済みパッケージで必要なパッケージのアップグレードを施していく。

 C#コードをちょっとだけ変更。
Program.cs

public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}

public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>();
}


 Startup.csの該当箇所を変更。
services.AddMvc();

  ↓
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);


 Dockerfileで使うコンテナを変更。これまではmicrosoft/aspnetcoreをつかっていたが、これは廃止されてmicrosoft/dotnetでタグ分けで配布されるようになるというハナシ。
https://blogs.msdn.microsoft.com/webdev/2018/04/12/asp-net-core-2-1-0-preview2-now-available/#user-content-deprecating-aspnetcore-and-aspnetcore-build-docker-images
Dockerfile

FROM microsoft/dotnet:2.1.0-rc1-aspnetcore-runtime


 さいごにTravisCIの設定YAML。使用するdotnet CLIを2.1対応のものに変更する。
.travis.yml

dotnet: 2.1.300


 いわゆるフレームワークのアップグレードを実行した。変更箇所が少なかったのがありがたいところ。一方で、動作を保証したいところには自動テストを組んでたことがやはりよかった。最低限の動作が壊れないことに加え、どこを変更すべきかという勘所をつけることができた。
comment: 0

WebアプリのテストでUIテストと統合テストを切り離そうとしたけど、ちょっと考えなければならなかったハナシ

 Webアプリのテストで、Seleniumを使ったUIテストを書いておいた。テスト内容として、フォームを埋めて、POSTでコントローラが意図通りに動いて、のぞんだレスポンスが返ってくるかまでテストしていた。UIテストと同時に統合テストの役割をになっていたので、役割をフォームのテスト程度に抑えて、別の統合テストを用意したかった。だからPython + requests( +lxml)で、直接RESTリクエストを投げてコントローラを動かすテストを書いてみた。
https://github.com/hMatoba/tetsujin/blob/master/tetsujin/PythonIntegtationTest/tests/master_test.py


 ただGETを投げてレスポンスを確認するようなテストもSeleniumでやっていたのだが、これをrequests + lxmlにしたところパフォーマンスは数十倍を超えたのでまあいい手ごたえ。ただSeleniumを使ったテストでも、一つのメソッドにつき一秒もかかっていないのでそんな神経質にならんでもいいかとも思う。
 考えなければならんかったのはPOSTを行うフォームのテスト。POSTする内容をrequestsで作る。それをPOSTで投げる。なのだが、フォームなのでもちろんCSRF対策が入っていて、これを考慮せんといかんかった。
 ASP.NET Core MVCでのCSRF対策は備え付けのものを使った。
Prevent Cross-Site Request Forgery (XSRF/CSRF) attacks in ASP.NET Core
CSRF対策のトークンを確認したところ、フォームには”__RequestVerificationToken”の名前で、クッキーには”.AspNetCore.Antiforgery.9TtSrW0hzOs”のキーで値が入っていた。だが、長さ190文字中、先頭30文字弱のみ一致しており、それ以降は異なっていた。これに関して自分で適当に作ってフォームとCookieにセットするのはやめておいたほうがよさそう。ここはSeleniumを使ってブラウザでフォームを動かしたほうが、自分でごにょごにょやるより手っ取り早いだろう。

 まとめ。WebアプリのUIテストと統合テストをわけたかったので、RESTリクエストを作ってコントローラを動かすテストを追加してみた。GETでレスポンス確認する場合は狙い通りだった。いっぽうでPOSTでCSRF対策が入っている場合、フォームやクッキーにその対策の対策を入れる必要に気づいた。そうすると、そこのあたりはSeleniumを使ってブラウザを動かすテストにしておくのが余計なことを考えずに済んで手っ取り早いだろうという知見を得た。
comment: 0

コントローラからOAuthの処理のべた書きを締め出して、Dependency Injectionで入れたらかなり納得する形になった

 ASP.NET Core MVCでOAuth(Githubの)を実装したのだが、実装を急いだので処理をコントローラにべた書き+グローバルな静的クラスに任せていた。それをリファクタリングして、なおかつOAuthは他でも使いまわせるようにしたかった。できればOAuthの処理の普遍な部分は抽象クラスにして、個別のWebアプリで実装すべき部分は派生クラスで実装するような。
 そんなクラスにOAuthを切り出して、それをコントローラにDependency Injectionしたらいい形になったなぁと思ったハナシ。

 まずOAuthの、Webアプリサーバサイドでの処理。
事前準備: OAuthを使わせてくれるサイトに必要情報を登録し、ClientIDとClientSecretをもらっておく。
以下OAuth処理
OAuthを使わせてくれるサイトへのリンクをクライアントに渡す

クライアントはリンクを踏む。一旦自サイトから外に出るが、リダイレクトで自分のサイトへ戻ってくる。戻ってくるときにコードを持っている

自分のサイトのサーバで、コードを使って、OAuthをやらせてくれるサイトからトークンをもらう

取得したトークンでOAuthをやらせてくれるサイトからユーザ情報をもらう

あとはユーザ情報を自サイトで判別

 問題のOAuth処理べた書きコントローラ。
[Route("OAuth")]

public async Task<IActionResult> OAuthAsync()
{
var clientId = GithubOAuth.ClientId;
var clientSecret = GithubOAuth.ClientSecret;


if (!Request.Query.ContainsKey("code"))
{
ViewData["OAuthLink"] = $"https://github.com/login/oauth/authorize?client_id={clientId}";
return View("OAuthAsync");
}

var code = Request.Query["code"];

var token = await GetAcessTokenAsync(clientId, clientSecret, code);

// 取得したトークンを使ってGithubにユーザ情報を要求する
var id = await GetUserId(token);
var loginSuccess = GithubOAuth.Login(id, Response.Cookies);

return Redirect(loginSuccess ? "/Master/" : "/Auth/OAuth");
}

private async Task<string> GetAcessTokenAsync(string clientId, string clientSecret, string code)
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

// 持って帰ってきた認証コードを使ってトークンを取得する
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", clientId },
{ "client_secret", clientSecret },
{ "code", code },
});
var response = await httpClient.PostAsync("https://github.com/login/oauth/access_token", content);
var responseBody = await response.Content.ReadAsStringAsync();
var jsonObj = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseBody);
var token = jsonObj["access_token"];

return token;
}

private async Task<string> GetUserId(string token)
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var uri = $"https://api.github.com/user?access_token={token}";
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Zenigata");
var response = await httpClient.GetAsync(uri);
var responseBody = await response.Content.ReadAsStringAsync();
var userInfo = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseBody);
var id = userInfo["id"];

return id;
}


 ビフォーアフターのアフターなコントローラ。OAuth処理を、専用のオブジェクト(インスタンス)に頼っている。専用のオブジェクトは、Dependency Injectionでコントローラにて使えるようになっている。
public class AuthorizationController : Controller

{
private readonly GithubOAuth _githubOAuth;

public AuthorizationController(GithubOAuth githubOAuth)
{
this._githubOAuth = githubOAuth;
}

[Route("OAuth")]
public async Task<IActionResult> OAuthAsync()
{
if (!Request.Query.ContainsKey("code"))
{
ViewData["OAuthLink"] = this._githubOAuth.GetRedirectUri();
return View("OAuthAsync");
}

var id = await this._githubOAuth.GetIdAsync(Request.Query["code"]);
var loginSuccess = this._githubOAuth.Login(id, Response.Cookies);

return Redirect(loginSuccess ? "/Master/" : "/Auth/OAuth");
}
}


 OAuth処理のベースをまとめた抽象クラス。ユーザにコードをもらうためにアクセスしてもらうURLや、サーバ側でコードを使ってトークン取得、ユーザ情報取得などがOAuth処理として普遍な機能なので実装されている。
using Microsoft.AspNetCore.Http;

using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;


namespace OAuthProvider
{
abstract public class GithubOAuthBase
{
public GithubOAuthBase(string clientId, string clientSecret)
{
this._clientId = clientId;
this._clientSecret = clientSecret;
}

private string _clientId = "";

public string ClientId
{
get
{
if (String.IsNullOrEmpty(_clientId))
{
throw new ArgumentNullException(
"Github OAuth client ID isn't given."
);
}
return _clientId;
}
}

private string _clientSecret = "";

public string ClientSecret
{
set => _clientSecret = value;
get
{
if (String.IsNullOrEmpty(_clientSecret))
{
throw new ArgumentNullException(
"Github OAuth client secret isn't given."
);
}
return _clientSecret;
}
}

public string GetRedirectUri()
{
var uri = $"https://github.com/login/oauth/authorize?client_id={this.ClientId}";
return uri;

}


public async Task<string> GetIdAsync(string code)
{
// 取得したトークンを使ってGithubにユーザ情報を要求する
var token = await GetAccessTokenAsync(code);

var id = await GetUserId(token);

return id;
}

virtual public bool Login(string id, IResponseCookies cookies)
{
throw new NotImplementedException();
}

virtual public async Task<bool> LoginAsync(string id, IResponseCookies cookies)
{
throw new NotImplementedException();
}

private async Task<string> GetAccessTokenAsync(string code)
{
// 認証コードを使ってトークンを取得する
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "client_id", this.ClientId },
{ "client_secret", this.ClientSecret },
{ "code", code },
});
var response = await httpClient.PostAsync("https://github.com/login/oauth/access_token", content);
var responseBody = await response.Content.ReadAsStringAsync();
var jsonObj = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseBody);
var token = jsonObj["access_token"];

return token;
}

private async Task<string> GetUserId(string token)
{
// 取得したトークンを使ってGithubにユーザ情報を要求する
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

var uri = $"https://api.github.com/user?access_token={token}";
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Zenigata");
var response = await httpClient.GetAsync(uri);
var responseBody = await response.Content.ReadAsStringAsync();
var userInfo = JsonConvert.DeserializeObject<Dictionary<string, string>>(responseBody);
var id = userInfo["id"];

return id;
}
}
}


 上記のクラスは抽象クラス。ユーザ情報をOAuthをやらせてくれるサイトから取ってきたら、その後の具体的な処理(認可を与えるべきか判断する、認可を与える)はサイトごとにことなる。それをLoginメソッドとして、↑の抽象クラスを継承した派生クラスで実装する。
using Microsoft.AspNetCore.Http;

using MongoDB.Driver;
using OAuthProvider;
using System;
using System.Security.Cryptography;
using tetsujin.Models;

namespace tetsujin
{
public class GithubOAuth : GithubOAuthBase
{
public GithubOAuth(string clientId, string clientSecret) : base(clientId, clientSecret) { }

/// <summary>
/// ログインを実行する
/// </summary>
/// <param name="id">ユーザID</param>
/// <param name="cookies">クッキー</param>
/// <returns>ログインの成否</returns>
override public bool Login(string id, IResponseCookies cookies)
{
var userCollection = DbConnection.Db.GetCollection<OAuthUser>(OAuthUser.CollectionName);
var filter = Builders<OAuthUser>.Filter.Eq("_id", id);
var master = userCollection.Find(filter).FirstOrDefault<OAuthUser>();
if (master == null) // ユーザが登録されていない場合
{
return false;
}
else // ユーザが登録されていた場合
{
// トークンを使ってセッションを開始
var token = GetToken();
var collection = DbConnection.Db.GetCollection<Session>(Session.CollectionName);
collection.InsertOne(new Session
{
Id = token,
CreatedAt = DateTime.Now
});

// cookieにsecure属性を付与
var cookieOption = new CookieOptions()
{
Secure = true
};
cookies.Append(OAuthUser.SessionCookie, token, cookieOption);

return true;
}
}

/// <summary>
/// ランダムトークンを取得
/// </summary>
/// <returns>ランダムトークン</returns>
private static string GetToken()
{
var rng = RandomNumberGenerator.Create();
var buff = new byte[25];
rng.GetBytes(buff);
return Convert.ToBase64String(buff);
}
}
}

 これでOAuth処理部分を、コントローラから任意のクラスに、うまく切り離すことができた。で、このOAuth処理を実装したクラスGithubOAuthのインスタンスをどうコントローラ内で参照できるようにするかという話である。クラスGithubOAuthはコンストラクタでClientIDとClientSecretを設定するので、静的クラスとしては使えず、インスタンス化する必要がある。
 インスタンスはいつ作る?リクエスト毎にコントローラのアクション内で…やるのは効率が悪い。インスタンスにはClientIDとClientSecretを保持してほしいが、これらはリクエスト毎に変えるものではない。アプリ立ち上げ時にあたえておくだけのもの。じゃあそれをやるために、ASP.NET Core MVCにあるDependency Injectionを使わせてもらう。
 ASP.NET Core MVCのDependency Injectionでコントローラにオブジェクトを渡すことができる。オブジェクトはクラスでもインスタンスでもいいし、インスタンスはその寿命を任意に与えられる。リクエスト毎か、アプリと同等の寿命を与えるかなど。今回はインスタンスにアプリと同等の寿命を与えるDependency Injectionにしている。
Startup.cs

public void ConfigureServices(IServiceCollection services)
{
services.Configure<GzipCompressionProviderOptions>
(options => options.Level = CompressionLevel.Fastest);
services.AddResponseCompression(options =>
{
options.Providers.Add<GzipCompressionProvider>();
});
services.AddMvc();

services.AddSingleton<HtmlEncoder>(
HtmlEncoder.Create(allowedRanges: new[] {
UnicodeRanges.BasicLatin,
UnicodeRanges.CjkSymbolsandPunctuation,
UnicodeRanges.Hiragana,
UnicodeRanges.Katakana,
UnicodeRanges.CjkUnifiedIdeographs
})
);

var githubClientId = Configuration.GetValue<string>("GITHUB_CLIENT_ID");
var githubClientSecret = Configuration.GetValue<string>("GITHUB_CLIENT_SECRET");
var githubOAuth = new GithubOAuth(githubClientId, githubClientSecret);
services.AddSingleton<GithubOAuth>(githubOAuth);
}

 これでコントローラにOAuth処理を提供してくれるインスタンスを渡すことができた。コントローラ作成時にしかインスタンス作成は行われないので不合理もない。Dependency Injectionで、コントローラにインスタンスを渡す満足な形にコードを持っていくことができた。

参考===============================
抽象クラス: http://ufcpp.net/study/csharp/oo_abstract.html
仮想メソッド: http://ufcpp.net/study/csharp/oo_polymorphism.html#virtual

Dependency Injection(オブジェクトの注入): https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/dependency-injection


comment: 0

MongoDBのC#ドライバ(2.4.4)で出たバグとその回避

 このブログのTravisCIで行っているテストがコケた。原因を追っていたら、MongoDBのC#ドライバの2.4.4.にバグがあった。

 データベースからコレクションリストをそのまま取得しようとするとエラーが出る。
var collections = db.ListCollections();


Unhandled Exception: System.FormatException: The GuidRepresentation for the reader is CSharpLegacy, which requires the binary sub type to be UuidLegacy, not UuidStandard.




 ドライバのバージョンが2.5で修正されているらしいが、2.5のリリース日程が見当たらない。Githubのリポジトリを見ても2.4.xまでしかブランチがない。ドライバのバージョンを下げずに今すぐどうにかならんかと。

 もともとListCollectionsでやっていたのは、コレクション一覧を取得してからの、任意の名前のコレクションがそこにあるかの確認。
var collections = DbConnection.db.ListCollections().ToList().Select(c => c["name"].AsString);


 ちょっとコードを変えたらエラーを回避できた。
var filter = new BsonDocument("name", collectionName);

var isExist = DbConnection.db.ListCollections(new ListCollectionsOptions { Filter = filter }).Any();


 もとはコレクションをリストで取ってきて、LINQでふるいをかけて、任意の名前のコレクションがあるかを確認していた。それを、コレクションでリストを取ってくるときにドライバ側で用意されたオプションを使って、ドライバで用意されたもので完結するように書き換えた。
comment: 0

このブログについて

ソース: https://github.com/hMatoba/tetsujin

 ブログのバックエンドを作るのが趣味になっているような気がする。
 はじめはPython on GoogleAppEngineで作って、それのデータベースをNoSQLにしてみたらどうだとか始めた。そのあとでTornadoやFlask、BottleといったPythonのフレームワークをいろいろやってみて、Node.jsはasync,awaitが入る前にやってちょっときついと思ってストップした。
 その後WPFで好きだったC#で作ってみたくなって、.NET Coreのリリース直後にAzureに乗るものを作った。そして今回、.NET Core 2.0のリリースがあったのを契機にDocker運用するものに作り直した。

 ASP.NET Core MVCを使ってみた感想。モデル、ビュー(Razor)、コントローラのみを使うような、SinatraやFlaskのような、うっすい使い方もできる。スキャフォルディングとかあるけど無理に使う必要はない。フレームワークに縛られてるような感覚は強くない。その点、うまくやれるかは書き手次第。


 このブログはタグに並べた要素が全部盛りで実装されている。
 C#が好きなので、それがオープンソースになったのがうれしかった。だからほかのオープンソース技術をメインに組み合わせて、C#はMSの用意したセット(WindowsOSでIISとかSQLServerとかって構成)じゃないと使えないなんてことはないというのを実践してみたかった。DebianでNginx、MongoDBなどって構成。
 使用サービスの構成としては、Github、TravisCI、Docker Hub、Hyper.shが主なところ。
・Github→バージョン管理
・TravisCI→CIサービス
・Docker Hub→ビルドしたコンテナイメージの置き場所
・Hyper.sh→Docker開発者が「これほぼDockerやんけ」と言うぐらいにDockerライクなコンテナホスティングサービス。もちろんDockerで作ったコンテナをホスティングできる
 使用サービスの構成はシンプルなほうがいいという一般論も存在するだろうけど、これはこれで楽をするための構成を敷いている。Dockerを使ったサーバの構成管理、自動テスト、CIサービス上での自動テストからの自動デプロイなどなど。アプリの開発継続に注力できる。

 Githubへのプッシュで、TravisCIでのビルド、テストが行われる。テストにパスしてなおかつブランチがmasterだったらならDocker Hubへビルドされたコンテナイメージがアップされる。そののち、Hyper.shでそのコンテナイメージをプル、デプロイまでのコマンドが自動で流れる。Docker Hubを経由しているので、コンテナイメージがビルドの産物として残るようになっている。


・運用お値段
 VPSは安い。でもOSの管理が必要だからヤダ。AWSとかAzureのイカしたPaasを使いたいけど高い。
 DockerコンテナホスティングをしてくれるHyper.shというサービスがあり、これで月2000円以下で運用できている。リバースプロキシコンテナ+アプリコンテナの2コンテナをランニングさせるコスト1000円に加え、個人的にいろんなアプリで使うMongoDBを仕方なくVPSで運用しているコスト600円程度となっている。VPSとメジャーどころのPaasの中間あたりのお値段。
 問題としてHyper.shが日本にもアジアにもリージョンがないことによるレイテンシがががというのがあるけども妥協。


・構成
 ファイルはAzureStorage
 ↑にも書いたけどコンテナホスティングで運用していて、走っているコンテナはリバースプロキシ用一台とアプリ用一台
 証明書はLet's Encrypt
 コンテナのOSは、リバースプロキシのほうがAlpine、アプリはDebian
 テストはxUnitによる単体テストが実行できるようにしてある
 それに加えてSeleniumでのE2Eテストも用意してある。Seleniumは、一度C#で動かしたらきつかったのでPythonで動かしている
 バージョン管理はGithub
 Githubへのデプロイで、TravisCIでのデプロイが走る
 TravisCIではとりあえずSeleniumでE2Eテストをやっている
 TravisCIでテストに成功したら、そこからHyper.shへ自動デプロイが始まる
 ただし自動デプロイが始まるのはmasterブランチへのコミット時のみ


 いろいろ構成していて半分は趣味で試したかったものであるが、同時にデプロイの自動化という一年前から突き詰めたかった課題への挑戦もしている。これのおかげで、今後このブログエンジンを改良していくのにせっせと手作業でデプロイをする必要がなくなった。


・なおしたい点
 dotnetコマンドが現状docker composeを使ったプロジェクトをビルドしてくれない。だから一部プロジェクトでCI環境でのビルドができず、開発環境でビルドしたものをDocker Hub経由で使っている。CI環境でビルドができないのはSDKの不足が原因。そのSDKはVisualStudio Communityに用意されているのだが、SDKがオープンソースになっていないという理由でdotnet CLIに入れるかどうかもたついている。
'17/9/28追記
docker composeが入っているプロジェクトのビルド方法を変えて対応完了

comment: 0