Blazor Hybrid-コンポーネントとホスト間I/F

以前、Blazor Hybridについて簡単に説明したが、razorコンポーネントとホスト間のやりとりについて、もう少し詳しく説明してみる。(記事のコメントにも少々文書で書いたが、今回は簡単な実例で説明してみる。)

まず、ホスト,コンポーネント双方で使用する、インターフェースとその実装を以下のような感じで作成する。

public delegate void OnClientEvent(object sender, EventArgs e);
public interface IHostInterface {
    public string Message { get; set; }
    public int Count { get; set; }
    /// <summary>
    /// コンポーネントのリフレッシュ等を行なうメソッド
    /// (コンポーネント側でセットする)
    /// </summary>
    /// <value></value>
    public Action ComponentRefresh { get; set; }
    /// <summary>
    /// コンポーネントでイベントが発生した時に呼ばれるホスト側イベントハンドラ
    /// </summary>
    public event OnClientEvent ComponentEvent;
    /// <summary>
    /// コンポーネント側からイベントを発生させるメソッド
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="e"></param>
    public void ExecClientEvent(object sender, EventArgs e);
}
public class HostInterface : IHostInterface {
    public string Message { get; set; } = null!;
    public int Count { get; set; } = 0;
    public Action ComponentRefresh { get; set; } = null!;
    public event OnClientEvent ComponentEvent = null!;
    public void ExecClientEvent(object sender, EventArgs e) {
        ComponentEvent.Invoke(sender,e);
    }
}

コンポーネント側では、このインターフェースを@injectでSingletonオブジェクトとして、使用できるようにする。

下記の例では、インターフェイス中のCounterプロパティをホストとの共有データとして使用し、ホスト側のトリガで、コンポーネントの更新が行えるよう、ComponentRefreshでStateHasChangedメソッドを呼び出せるようにしている。

また、コンポーネント側のイベントを、ホストにも知らせることができるよう、イベント発生時に、ホスト側のイベントハンドラを呼び出している。

@inject IHostInterface HostIF

<h1>Counter</h1>

<p>Current count: @HostIF.Count</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    protected override void OnInitialized() {
        // Componentの再描画メソッドを設定
        HostIF.ComponentRefresh = (Action)(()=>StateHasChanged());
    } 

    private void IncrementCount() {
        // カウントアップした後、ホスト側へイベント通知
        HostIF.Count++;
        HostIF.ExecClientEvent(this,new EventArgs());
    }
}

ホスト側では、コンストラクタで、ComponentEventにハンドラを設定することにより、コンポーネントで状態変更が有った場合の処理を定義できる。

private HostInterface HostIF;
public Form1()
{
    InitializeComponent();
    var services = new ServiceCollection();
    services.AddWindowsFormsBlazorWebView();
    // Singletonオブジェクトの作成
    HostIF = new HostInterface();
    // コンポーネントから呼び出されるイベントハンドラ
    HostIF.ComponentEvent += On_ClientEvent;
    // Singletonオブジェクトをコンポーネントサービスに追加
    services.AddSingleton<IHostInterface>(HostIF);
    blView.HostPage = @"wwwroot\index.html";
    blView.Services = services.BuildServiceProvider();
    blView.RootComponents.Add<Counter>("#app");
}
/// <summary>
/// インクリメントボタンクリックイベントハンドラ
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
public void On_btnIncrement_Click(object sender, EventArgs e) {
    // Singletonのカウンタをインクリメント
    HostIF.Count++;
    lblMessage.Text = $"カウント:{HostIF.Count}";
    // コンポーネント表示を更新
    HostIF.ComponentRefresh();
}
/// <summary>
/// コンポーネントイベントハンドラ
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
public void On_ClientEvent(object sender, EventArgs e) {
    // ホストの表示を更新
    lblMessage.Text = $"カウント:{HostIF.Count}";
}

実行すると以下のように、ホストとコンポーネント間の同期が取れていることが分かる。

何かに使えるかも知れないので、メモ。

カテゴリー: .NET, Blazor, C#, 技術系 | コメントする

Microsoft Graphでアカウントパスワード変更

Office365のアカウントパスワードを忘れる社員が多いので、なんとか自動化したいなと思い、以下のようなフローでパスワードリセットを行なう仕組みを考えた。

Microsoft Graphを使用してユーザーのパスワードを強制的に再設定するには、下記のように、PasswordProfileを使用する必要がある。Microsoft Graph APIの.ChangePassword()メソッドを使用するには、旧パスワードが必須なため旧パスワードが分からないと、変更できないので・・・

using Microsoft.Graph;
using Azure.Identity;
・・・
var options = new TokenCredentialOptions
{
    AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};
var cred = new UsernamePasswordCredential(AdminUsername,AdminPassword,TenantId,ServiceId,options);

// scopeにはUser.ReadWrite.All,Directory.ReadWrite.Allが必要
var graphClient = new GraphServiceClient(cred,scopes);

// テンポラリパスワードの生成
string newPassword = GeneratePassword();
// パスワードの変更
try {
    User user = new User();
    user.PasswordProfile = new PasswordProfile() {
        ForceChangePasswordNextSignIn = true,           // 次のログイン時にパスワード強制変更
        ForceChangePasswordNextSignInWithMfa = false,
        Password = newPassword                          // テンポラリパスワード
    };
    // パスワード情報の更新
    await graphClient.Users[targetaccount]
        .Request()
        .UpdateAsync(user);
} catch (Exception e) {
    Message = $"パスワードリセットに失敗しました。<br/>{e.Message}";
    return;
}

ちなみに、HTTPメソッドはUpdateAsync()=PATCHを使用する。

備忘録として。

カテゴリー: .NET, C#, Microsoft Graph, 技術系 | 1件のコメント

bootstrapモーダルダイアログとblazor

bootstrapでは、標準でモーダルダイアログをサポートしている。以下のような感じで、divタグへ特定のCSSクラス名を指定するだけで、簡単にモーダルダイアログを表示させることができる。

<button class="btn btn-primary" data-toggle="modal" data-target="#Modal">ユーザー選択</button>
<br/>
<span style="color:red;font-weight:bold">@mesg</span>
<!-- モーダル本体 -->
<div class="modal fade" id="Modal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
	<div class="modal-dialog modal-dialog-centered" role="document">
		<div class="modal-content">
			<!-- モーダルヘッダ部 -->
			<div class="modal-header" style="background-color:navy;color:white">
				<h5 class="modal-title" id="exampleModalLongTitle">Modal Test</h5>
				<button type="button" class="close" data-dismiss="modal" aria-label="Close">
				  <span aria-hidden="true">×</span>
				</button>
			</div>
			<!-- モーダルボディ -->
			<div class="modal-body" style="height:500px">
			@if (Employees != null) {
				<select size="20" style="width:200px" @bind="selectedEmpNo">
					@foreach(var itm in Employees) {
					  <option value="@itm.EmpNo">@itm.Name</option>
					}
				</select>
			}
			</div>
			<!-- モーダルヘッダ -->
			<div class="modal-footer">
				<button type="button" class="btn btn-secondary" @onclick="OnCancelClick">キャンセル</button>
				<button type="button" class="btn btn-primary" @onclick="OnOKClick">選択</button>
			</div>
		</div>
	</div>
</div>

blazorでも以下のscriptを読込んでおけば、使用できる。

<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>

モーダルのクローズをJavascriptから明示的に行なうには、modal(‘hide’)を使用する。

var modalope = {
    close: (target) => {
        var eleid = '#' + target;
        $(eleid).modal('hide');
    },
    open: (target) => {
        var eleid = '#' + target;
        $(eleid).modal();
    }
};

以下はblazorのcode部分

@code {
	protected string mesg = "";
	protected string selectedEmpNo = "";
	protected List<Employee> Employees = null!;
		・・・
	protected async Task OnOKClick() {
		await JS.InvokeVoidAsync("modalope.close","Modal");
		var emp = Employees.Where(v=>v.EmpNo == selectedEmpNo).FirstOrDefault();
		if (emp != null) {
		  mesg = $"「{emp.EmpNo}:{emp.Name}」が選択されたよ!!";
		}
	}
	protected async Task OnCancelClick() {
		await JS.InvokeVoidAsync("modalope.close","Modal");
		mesg = "選択がキャンセルされたよ!!";
	}
	・・・
}

実行結果はこんな感じ

実行画面1
実行画面2

もちろん、divのidを変えれば、複数のダイアログを使用することが可能。

結構便利に使えそう。

カテゴリー: .NET, asp.net core, Blazor, C#, 技術系 | コメントする

ZipArchiveによる、ZIPファイルの作成

ZipファイルにアクセスするためのライブラリとしてはDotNetZipが有名だが、.NET標準のSystem.IO.Compression.ZipArchiveクラスを使用した、ZIPファイルの作成についての備忘録を書いてみようと思う。

なぜ、ZipArchiveを利用しようとしたのかというと、最初はDotNetZipを使ってコードを書いたのだが、Webページからのダウンロードがうまく動作せず、原因を突き止めるより、別のライブラリを使用した方が早いかなという感じで、色々調べてみたのだが、まぁ、.NET標準だし、これでいいかなと採用したわけ。

ZIPファイルを作成するには、ZipFileクラスを使った方が簡単なのだが、今回はWebアプリで、動的に作成した内容をZip化したいので、ZipArchiveクラスを使用する。

ZipArchiveクラスは書込先となるStreamを指定して作成する。その中にエントリを追加して行く形だ。

実際のコードを見てみると以下のような感じ。

using System.IO;
using System.IO.Compression;
・・・
using(MemoryStream zipstream = new MemoryStream()) {	// ZipArchiveを書込むStream
	using(ZipArchive ar = new ZipArchive(zipstream,ZipArchiveMode.Create,true)) {	// ZipArchiveの作成
		// ZipEntry=ファイルの作成
		ZipArchiveEntry ent = ar.CreateEntry(entry1);
		using(Stream stm = ent.Open()) { // エントリのStreamを取得
			using(StreamWriter wtr = new StreamWriter(stm,Encoding.GetEncoding("shift_jis"))) {	// 文字列書き込み用StreamWriter
				// ファイル内容作成
				・・・
				await wtr.FlushAsync();
				await stm.FlushAsync();
			}
		}
		// 必要分上記の繰返し
		・・・
	}
	await zipstream.FlushAsync();
	byte[] buff = zipstream.ToArray();
	return File(buff,"application/octet-stream",ZipFileName);
}

DotNetZipと比べると多少面倒で、パスワードがけられない等の問題もあるけど、目的が果たせたから良しとしよう。

カテゴリー: .NET, C#, 技術系 | コメントする

勘違い(MultipartFormDataContent+WebAPI)

メール送信を行なう、WebAPIを作成しようとして、以下のようなインターフェイスを考えた。

[HttpPost]
public async Task<SendMailStatus> PostAsync(
    [FromForm]string? FromAddress,		// 送信者アドレス
    [FromForm]string? Password,			// (暗号化された)パスワード
    [FromForm]string[]? ToAddresses,	// 送信先(複数指定可)
    [FromForm]string[]? CcAddresses,	// Cc(複数指定可)
    [FromForm]string[]? BccAddresses,	// Bcc(複数指定可)
    [FromForm]string? Subject,			// 件名
    [FromForm]string? Message,			// 本文
    [FromForm]IFormFile[]? Attachments	// 添付ファイル(複数指定可)
) {
	・・・

添付ファイルがあるので、当然、multipart/form-dataとして、クライアントからデータをPOSTしなければならないのだが、この作成方法に引っかかってしまったのであった。

MultipartFormDataContent構造体を使用するのは分かっており、これにファイルを追加するにはBinaryContent構造体を使用すれば良いことも分かっていたのだが、それ以外のFormデータを「Formデータだから、FormUrlEncodedContent構造体を作って、それを追加すればいいんじゃ無いか?」と勝手に思い込んで、色々試したが、ファイル(コード上はAttachments)は取れるのだが、他の引数はうまくデータがとれず、「何じゃこれは?」となった・・・

色々調べて、結局FormUrlEncodedContentでまとめて入れるのでは無く、以下のように個々のパラメータ(key=値)をStringContentとして別々に追加しなければいけないことが分かった。

// マルチパートMime
MultipartFormDataContent MimeData = new MultipartFormDataContent();

// 送信元
StringContent ctx = new(FromAddress);
MimeData.Add(ctx,"FromAddress");

// パスワード
string encPwd = EncryptPassword(Password));
ctx = new(encPwd);
MimeData.Add(ctx,"Password");

// 宛先
foreach(string s in ToAddresses) {
    if (s != null) {
        ctx = new(s);
        MimeData.Add(ctx,"ToAddresses");
    }
}
	・・・
// 添付ファイル
foreach(var a in Attachments) {
    MemoryStream stm = new MemoryStream();
    await a.CopyToAsync(stm);
    await stm.FlushAsync();
    byte[] buf = stm.ToArray();
    ByteArrayContent c = new ByteArrayContent(buf);
    MimeData.Add(c,"Attachments",a.FileName);
}
await cli.PostAsync("・・・",MimeData);

まったく、基礎がなっておりませぬな・・・

無駄な時間を使ってしまった・・・

カテゴリー: .NET, asp.net core, C#, Web, 技術系 | コメントする

.NET7のJSONシリアライザ

.NET7になって、JSONシリアライザがアップデートされ、今までサポートしていなかったDateOnly型やTimeOnly形の変換を行ってくれるようになった。

.NET6と.NET7を比べると以下のよう感じ。(dotnet-scriptで実行)

using System.Text.Json;
public class Person {
  public string Name { get; set; }
  public DateOnly Birthday { get; set; }
}
List<Person> plst = new();
Person p = new() { Name="T.Sumomo", Birthday=new DateOnly(1964,2,3)};
plst.Add(p);
p = new() { Name="J.Sumomo", Birthday=new DateOnly(1988,5,13)};
plst.Add(p);
string json = JsonSerializer.Serialize(plst);
json

結果(.NET6)

System.NotSupportedException: System.NotSupportedException: Serialization and deserialization of 'System.DateOnly' instances are not supported. Path: $.Birthday.
  + System.Text.Json.ThrowHelper.ThrowNotSupportedException(ref System.Text.Json.WriteStack, System.NotSupportedException)
  + JsonConverter<T>.WriteCore(System.Text.Json.Utf8JsonWriter, ref T, System.Text.Json.JsonSerializerOptions, ref System.Text.Json.WriteStack)
  + System.Text.Json.JsonSerializer.WriteUsingSerializer<TValue>(System.Text.Json.Utf8JsonWriter, ref TValue, System.Text.Json.Serialization.Metadata.JsonTypeInfo)
  + System.Text.Json.JsonSerializer.WriteStringUsingSerializer<TValue>(ref TValue, System.Text.Json.Serialization.Metadata.JsonTypeInfo)
  + System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, System.Text.Json.JsonSerializerOptions)

DateOnlyのシリアライズ・デシリアライズをサポートしていないので、エラー

結果(.NET7)

"[{\"Name\":\"T.Sumomo\",\"Birthday\":\"1964-02-03\"},{\"Name\":\"J.Sumomo\",\"Birthday\":\"1988-11-23\"}]"

DateOnlyのシリアライズ・デシリアライズをサポートしたので、正常にJSONにシリアライズされる。

もちろん、デシリアライズもOK

var xlst = JsonSerializer.Deserialize<List<Person>>(json);
xlst

結果

List<Submission#1.Person>(2) { Submission#1.Person { Birthday=[1964/02/03], Name="T.Sumomo" }, Submission#1.Person { Birthday=[1988/11/23], Name="J.Sumomo" } }

大した変更ではないが、結構ありがたい。

カテゴリー: .NET, C#, 技術系 | コメントする

INumber<T>インターフェース

.NET7から数値系の型はすべてSystem.Numerics.INumber<T>インターフェースを実装することとなった。


INumber<T>には数値への四則演算やその他基本的な演算が含まれているので、数値系のジェネリクスクラスが作成しやすくなっている。

簡単な例を以下に挙げる。

using System.Numerics;
public class NumberList<T> where T :INumber<T> {
	protected List<T> Numbers { get; set; }
	public  NumberList() {
		Numbers = new ();
	}
	public NumberList(IEnumerable<T> Values):
		this() {
		Numbers.AddRange(Values);
	}
	public void Append(T Value) {
		Numbers.Add(Value);
	}
	public T Sum() {
		T sum = T.Zero;
		foreach(var v in Numbers) {
			sum += v;
		}
		return sum;
	}
	public void Map(Func<T,T> Operation) {
		for(int i=0; i < Numbers.Count; i++) {
			Numbers[i] = Operation(Numbers[i]);
		}
	}
	public IEnumerator<T> GetEnumerator() {
		foreach(var v in Numbers) {
			yield return v;
		}
	}
}
List<int> inumbers = new () { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
NumberList<int> ilst = new (inumbers);
Console.WriteLine(ilst.Sum());
ilst.Map((v)=>v*v);
foreach(var v in ilst) {
	Console.Write($"{v},");
}
Console.WriteLine();
List<double> dnumbers = new () { 0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1.0 };
NumberList<double> dlst = new (dnumbers);
Console.WriteLine(dlst.Sum());
dlst.Map(Math.Sqrt);
foreach(var v in dlst) {
	Console.Write($"{v.ToString("0.0000")},");
}
Console.WriteLine();

結果はこんな感じ

55
1,4,9,16,25,36,49,64,81,100,
5.5
0.32,0.45,0.55,0.63,0.71,0.77,0.84,0.89,0.95,1.00,
カテゴリー: .NET, C#, 技術系 | コメントする

.net7.0 C#11

ついに.net7.0が正式リリース。C#11も正式にサポートされるようになった。

.net7.0+C#11には色々な機能追加があるが、私が1番よく使うであろう物は「生文字リテラル」かな。MSのサイトでは「未加工の文字リテラル」と翻訳されているが、要は文字列中に「”」や「{」等をエスケープ文字無しで書ける構文だ。

以下のような感じで文字列を定義できる。

string jsonstr = """
[
    {"Name": "T.Sumomo", "Email": "t.sumomo@momo.com"},
    {"Name": "J.Sumomo", "Email": "j.sumomo@momo.com"},
    {"Name": "S.Sumomo", "Email": "s.sumomo@momo.com"}
]
""";

文字列補間も可能で、以下のような感じ

int x = 100;
int y = 200;
string replstr = $$"""
    { "X" : {{x}}, "Y": {{y}} }
""";

文字列補間と認識させる「{」の数分、「$」を付ける。

ちなみに、「”」の数も同じような感じで、「”””」までエスケープ無しならば、「””””」とする。

string example = """"
string jsonstr = """
[
    {"Name": "T.Sumomo", "Email": "t.sumomo@momo.com"},
    {"Name": "J.Sumomo", "Email": "j.sumomo@momo.com"},
    {"Name": "S.Sumomo", "Email": "s.sumomo@momo.com"}
]
""";
"""";

上記一応、動作確認してます。

後、別記事にするつもりだけど、数値系がINumber<T>をインプリメントしたこと。これにより、intやdouble等数値専用のジェネリッククラスの定義が簡単になった。それと、Int128かな。

カテゴリー: .NET, C#, 技術系 | コメントする

MailKit SaslMechanismOAuth2

前のブログ記事のコメントに、MailKitの「SaslMechanism(OAuth2)は使い回しができないようだ。」と書いたけど、Resetメソッドを使うと、問題無く動きました。このメソッドが何をやっているかというと、IsAuthenticateプロパティをfalseに設定しているだけなんだけどね。(ベースクラスで実装されてた)

var cred = new UsernamePasswordCredential(PopUser,PopPass,Authority,AppID,options); 
// Scopesは["https://outlook.office365.com/POP.AccessAsUser.All","https://outlook.office365.com/SMTP.Send"]
var req = new TokenRequestContext(scopes);
var token = await cred.GetTokenAsync(req);
var oauth2 = new SaslMechanismOAuth2(PopUser, token.Token);

using (Pop3Client cli = new Pop3Client()){
    await cli.ConnectAsync("outlook.office365.com", 995, SecureSocketOptions.SslOnConnect);
    // AccessTokenで認証
    await cli.AuthenticateAsync(oauth2);
    int cnt = await cli.GetMessageCountAsync();
	・・・
}
using (SmtpClient scli = new SmtpClient()) {
    MimeMessage msg = new MimeMessage();
	・・・
    // SASLの認証済みフラグを落とす
    oauth2.Reset();

    await scli.ConnectAsync("outlook.office365.com", 587, SecureSocketOptions.StartTls);
    await scli.AuthenticateAsync(oauth2);
    await scli.SendAsync(msg);
    await scli.DisconnectAsync(true);
}

もちろん、長時間使用していない場合は、AccessTokenがExpireしてしまうので、その場合はAcessTokenの取得から行なわなければならないけど・・・

カテゴリー: .NET, OAuth2, 技術系 | コメントする

Azure.Identity+MailKitでOAuth2 POP3

Microsoftは、SMTPやPOP3,IMAPの基本認証プロトコルでのアクセス許可を9月30日よりサイト毎に順次停止していく予定だ。

これにより、基本認証を使用してPOP3やIMAPなどでメールを受信するプログラムが動作しなくなる可能性が高くなる。

これは私も承知していて、MailKitのSaslMechanismOAuth2クラスを使用してOAuth2対応にするよう修正していたのだが、その際に使用したMicrosoft.Identity.ClientMicrosoft.Graph.Authライブラリが以前書いたように、非推奨となってしまったので、Azure.Identityに変更してみた。

基本はMicrosoft Graphを使う場合と同じなのだが、注意点として、ユーザーやグループを捜査するMS GraphではスコープのPrefixが必要では無かったのだが、POP3やIMAP,SMTP等では、”https://outlook.office365.com/”のPrefix(ネームスペースなのかな?)が必要となる事。

POP3の場合は以下のようなコードとなる。

・・・
using MimeKit;
using MailKit;
using MailKit.Net.Pop3;
using MailKit.Security;
using MimeKit.Text;

using Azure.Core;
using Azure.Identity;
・・・
// この例では、Resource Owner Password Credentialsフローを使用

// スコープ
string[] scopes = new string[] { "https://outlook.office365.com/POP.AccessAsUser.All" };

var options = new TokenCredentialOptions
{
    AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
};

// ユーザー名,パスワード,テナントID,アプリケーションID(Client ID)から資格情報を得る
var cred = new UsernamePasswordCredential(PopUser,PopPass,Authority,AppID,options); 

// スコープを指定してAccess Tokenの取得
var req = new TokenRequestContext(scopes);
var token = await cred.GetTokenAsync(req);

// ユーザー名とAccess Tokenを使用してOAuth2用の認証機構を作成
var oauth2 = new SaslMechanismOAuth2(PopUser, token.Token);

using (Pop3Client cli = new Pop3Client())
{
    await cli.ConnectAsync("outlook.office365.com", 995, SecureSocketOptions.SslOnConnect);
    // OAuth2で認証
    await cli.AuthenticateAsync(oauth2);
	・・・

最初、Authentication Failが発生したので、アカウントを変えたり、色々してみたが、全然だめなので、もしやと思い、スコープにPrefixを付けたら、うまく動作した。

OAuth2対応についてはネットで調べても、Microsoft.Identity.Clientを使用した例ばかりで、解決に結構時間が掛かってしまった。

カテゴリー: .NET, C#, OAuth2, 技術系 | 9件のコメント