20210128のC#に関する記事は3件です。

【C#入門】初学者がASP.NETでWebアプリを作る:第4回

今回やること

アカウント認証を実装します。

初めに

既存のプロジェクトにASP.NET Identityを導入する方法はないようです。。。
ダミーで認証ありのプロジェクトを作って、そこから必要なものをコピーして編集して…みたいな方法しかなかったです。
さすがに面倒くさすぎるので、最初から作り直すことにしました。

認証機能付きプロジェクトを作る

認証を「個別のユーザアカウント」で作成します。
image.png

差分を取り込む

NuGetでNpgsqlをインストールします。
上記で作成したプロジェクトともともとのプロジェクトの差分を取り込みます。(第2回参照)

appsettings.jsonのDefaultConnectionをPostgresに戻します。

appsettings.json
{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Port=5432;Database=SMSDB;User ID=postgres;Password=password;Enlist=true"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

Startup.csのConfigureServicesのUseSqlServerをUseNpgsqlに戻します。

Startup.cs
// This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                //options.UseSqlServer(
                options.UseNpgsql(
                    Configuration.GetConnectionString("DefaultConnection")));
            services.AddDatabaseDeveloperPageExceptionFilter();

            services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
                .AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddControllersWithViews();
        }

自分で作ったファイルたちをコピーします。
コントローラ、モデル、ビュー等。(WinMergeで差分を見つけていきました)

次に自前のDbContextからApplicationDbContextに一部記述を移植して、自前のDbContextを削除、自前のDbContextを参照している個所をApplicationDbContextに書き換えます。

ApplicationDbContext.cs
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Text;

namespace SalaryManagementSystem.Data
{
    public class ApplicationDbContext : IdentityDbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        public DbSet<SalaryManagementSystem.Models.Salary> Salary { get; set; } // 移植箇所
    }
}

最後に、2つあるMigrationsフォルダを丸ごと削除します。
Data配下とプロジェクト直下にあります。

パッケージマネージャコンソールから、Add-MigrationとUpdate-DataBaseコマンドを実行します。
認証用のテーブルを作るためのマイグレーションが必要なのですが、上記コマンドを実行するためには一度既存のマイグレーションによる生成ファイルは削除する必要がありました。
そのため、Migrationsフォルダを削除しています。
また、DbContextは1つに統合できたので統合しました。

マイグレーションのコマンドで、モデルに対応するテーブル(Salaryテーブル)は既にあるので警告が出ます。
このテーブルだけでなく全部のテーブル作成がロールバックされてしまうので、Salaryテーブルは削除して、実行します。
実行できると、認証機能用のテーブルができます。
image.png

ひとまず起動してみる

右上にRegisterとLoginが表示されるようになりました。
image.png

例によって日本語化しておきます。ログイン周りの定義は_Layout.cshtmlから呼び出している_LoginPartical.cshtmlに定義されています。

_LoginPartical.cshtml
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager

<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    <li class="nav-item">
        <a  class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">ようこそ @User.Identity.Name!</a>
    </li>
    <li class="nav-item">
        <form  class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })">
            <button  type="submit" class="nav-link btn btn-link text-dark">ログアウト</button>
        </form>
    </li>
}
else
{
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Register">アカウント登録</a>
    </li>
    <li class="nav-item">
        <a class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Login">ログイン</a>
    </li>
}
</ul>

デフォルトの動きを確認

まずアカウント登録の画面に遷移すると下記のような画面になります。
メールアドレスを入力するようになっていますが、面倒なのでユーザIDにしたいです。
あと右側の文言は「外部サービス連携は使えませんよ」的なメッセージなので消したいです。
適当に入力しRegisterを押します。
image.png

…先に言ってほしいやつですね。この辺を右側に書きましょうか。
image.png

登録が完了しました。
image.png

ログインしましょう。
image.png

ログイン失敗しました。どうやらメール認証しないとダメみたいですね。
メール認証…いったんオフにしたいですね…
今回はテーブルデータを更新して、認証したことにします。
image.png

AspNetUsersテーブルのEmailConfirmedをtrueにします。
image.png

もう一度ログイン。ログインできました。メールアドレス=ユーザIDはちょっと気に入らないですね。
image.png

変更したいところ

・メールアドレスとパスワードではなく、IDとパスワードにしたい。(あと表示名)
 →つまり独自のユーザー情報モデルを作る
・登録画面の右のペインはパスワードのルールに書き換える

独自のユーザー情報モデルを作る

こちらを参考にします。
https://kiyokura.hateblo.jp/entry/2014/06/23/010749

ユーザ情報モデルはSMSUserというクラスにします。ASP.NET CoreだとIUserではなくIdentityUserというインターフェースになります。

SMSUser.cs
using Microsoft.AspNetCore.Identity;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;

namespace SalaryManagementSystem.Models
{
    [Table("SMSUser")]
    public class SMSUser : IdentityUser
    {
        [Key]
        [Column("UserId")]
        [Display(Name = "ユーザーID")]
        public string SMSUserId { get; set; }

        [Column("UserName")]
        [Display(Name = "ユーザー名")]
        public string SMSUserName { get; set; }

        [Display(Name = "パスワード")]
        public string Password { get; set; }

        public bool IsLocked { get; set; }

        public int LoginFailCount { get; set; }

        public DateTime CreateDate { get; set; }

        public DateTime UpdateDate { get; set; }

        [NotMapped] public override string Id { get; set; }
        [NotMapped] public override string NormalizedUserName { get; set; }
        [NotMapped] public override string Email { get; set; }
        [NotMapped] public override string NormalizedEmail { get; set; }
        [NotMapped] public override bool EmailConfirmed { get; set; }
        [NotMapped] public override string PasswordHash { get; set; }
        [NotMapped] public override string SecurityStamp { get; set; }
        [NotMapped] public override string ConcurrencyStamp { get; set; }
        [NotMapped] public override string PhoneNumber { get; set; }
        [NotMapped] public override bool PhoneNumberConfirmed { get; set; }
        [NotMapped] public override bool TwoFactorEnabled { get; set; }
        [NotMapped] public override DateTimeOffset? LockoutEnd { get; set; }
        [NotMapped] public override bool LockoutEnabled { get; set; }
        [NotMapped] public override int AccessFailedCount { get; set; }

    }
}

ユーザストアクラスを作ります。雰囲気で実装しました。よくわかっていません…。

C#SMSUserStore.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SalaryManagementSystem.Data;

namespace SalaryManagementSystem.Models
{
    public class SMSUserStore : IUserPasswordStore<SMSUser>
    {
        private ApplicationDbContext _context;
        private static List<SMSUser> Users = new List<SMSUser>();

        public SMSUserStore(ApplicationDbContext context)
        {
            _context = context;
        }
        public Task<IdentityResult> CreateAsync(SMSUser user, CancellationToken cancellationToken)
        {
            _context.SMSUsers.Add(user);
            _context.SaveChangesAsync();
            return (Task<IdentityResult>)Task.Delay(0);
        }

        public Task<IdentityResult> DeleteAsync(SMSUser user, CancellationToken cancellationToken)
        {
            _context.SMSUsers.Remove(user);
            _context.SaveChangesAsync();
            return (Task<IdentityResult>)Task.Delay(0);
        }

        public void Dispose()
        {
            // 何もしない
        }

        public Task<SMSUser> FindByIdAsync(string userId, CancellationToken cancellationToken)
        {
            return _context.SMSUsers.FirstOrDefaultAsync(u => u.SMSUserId == userId, cancellationToken);
        }

        public Task<SMSUser> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
        {
            return _context.SMSUsers.FirstOrDefaultAsync(u => u.SMSUserName.ToUpper() == normalizedUserName, cancellationToken);
        }

        public Task<string> GetNormalizedUserNameAsync(SMSUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.SMSUserName.ToUpper(), cancellationToken);
        }

        public Task<string> GetPasswordHashAsync(SMSUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.Password, cancellationToken);
        }

        public Task<string> GetUserIdAsync(SMSUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.SMSUserId, cancellationToken);
        }

        public Task<string> GetUserNameAsync(SMSUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => user.SMSUserName, cancellationToken);
        }

        public Task<bool> HasPasswordAsync(SMSUser user, CancellationToken cancellationToken)
        {
            return Task.Run(() => string.IsNullOrEmpty(user?.Password), cancellationToken);
        }

        public Task SetNormalizedUserNameAsync(SMSUser user, string normalizedName, CancellationToken cancellationToken)
        {
            throw new NotSupportedException("NormalizedUserName is not supported.");
        }

        public Task SetPasswordHashAsync(SMSUser user, string passwordHash, CancellationToken cancellationToken)
        {
            user.Password = passwordHash;
            return (Task<IdentityResult>)Task.Delay(0);
        }

        public Task SetUserNameAsync(SMSUser user, string userName, CancellationToken cancellationToken)
        {
            user.SMSUserName = userName;
            return (Task<IdentityResult>)Task.Delay(0);
        }

        public Task<IdentityResult> UpdateAsync(SMSUser user, CancellationToken cancellationToken)
        {
            _context.SMSUsers.Update(user);
            _context.SaveChangesAsync();
            return (Task<IdentityResult>)Task.Delay(0);
        }
    }
}

これに伴い、DBContextも修正しています。
継承クラスをIdentityDbContext→DbContextに変更し、
SMSUsersを追加しました。

ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }

        public DbSet<SalaryManagementSystem.Models.Salary> Salary { get; set; }
        public DbSet<SalaryManagementSystem.Models.SMSUser> SMSUsers { get; set; }
    }

最後にStartUp.csを修正します。

Startup.cs
public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<ApplicationDbContext>(options =>
                options.UseNpgsql(
                    Configuration.GetConnectionString("DefaultConnection")));
            services.AddDatabaseDeveloperPageExceptionFilter();

            //services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
            //    .AddEntityFrameworkStores<ApplicationDbContext>();
            services.AddIdentity<SMSUser, IdentityRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();
            services.AddControllersWithViews();
        }

テーブル(SMSUser)がないので、作ります。
なぜかスキャフォールディングでモデルクラスとして選べなかったので、手動で作りました。
image.png

次回予告

長くなってきたので次回に持ち越します。
「あっこいつ間違っとるな…」と思ったらコメントで教えていただけるととても助かります…。

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

バイト数での部分文字列取得

C#(.Net)の部分文字列取得処理は String.SubString メソッド がありますが、これを文字列長ではなくバイト長で処理したいという要望が稀に良くあります。
日本だからね。仕方ないね。

ネット上に色々なサンプルがありますが、ここはシンプルにLinqで書き上げて見ましょう。

サンプルコード

using System.Linq;
using System.Text;

namespace MySample
{
    public class StringUtil
    {
        public static string ByteSubString(string text, int byteLength, int startByteIndex = 0, Encoding encode = null)
        {
            // エンコード未指定時はShift-JISを仮置き
            encode = encode ?? Encoding.GetEncoding("Shift-JIS");

            var cutChars = text
                .SkipWhile((x, i) => encode.GetByteCount(targetValue.Substring(0, i + 1)) <= startByteIndex)
                .TakeWhile((x, i) => encode.GetByteCount(targetValue.Substring(0, i + 1)) <= byteLength)
                .ToArray();

            return new string(cutChars);
        }
    }
}

コード解説

簡単に解説をば。

まず考え方としては文字列を文字配列(char[])として捉えます。
文字配列を Enumerable.SkipWhile メソッド で開始位置のバイト数を満たすまで先頭から読み飛ばします。
その後、呼び飛ばした後の文字列を Enumerable.TakeWhile メソッド でバイト長を満たすまで取得します。
最後に取得したchar配列をstringのコンストラクタに投げてやれば文字列として返るので完成です。

汎用性を持たせるために、開始位置とか文字コードの指定は任意で出来るようにしてみました。
ここら辺はお好みでどーぞ。


Q&A

Q. 指定した開始位置が2バイト文字の間になる場合はどうなるの?

Skipの条件が「先頭から1文字ずつ確認し、開始位置を超えるまで」なので、指定バイト数より少なくSkipする事になります。
(例:"ほげふがぴよ" で開始位置指定が「3」の場合は "げふがぴよ"。実際に開始している位置は2バイト目になる。)

Q. 指定したバイト長が2バイト文字の間になる場合はどうなるの?

Takeの条件が「先頭から1文字ずつ確認し、指定バイト長を超えるまで」なので、指定バイト数より少なくTakeする事になります。
 (例:"ほげふがぴよ" で取得バイト長が「5」の場合は "ほげ"。実際に返すバイト長は4バイトになる。)

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

[C#] ラムダ式内でラムダ式外のループカウンタ変数を使用すると危険

Teratailで同じような原因の質問が別々の方からあったのでメモ代わりに。
C#のTask配列をforでセットしようとするとエラーになる
基本的な質問ですが、Taskの結果がバラバラになります

一見普通に動きそうなコードだけど…

さて、まずは下記のサンプルコードを見てください。

    Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [for Start]");
    for (var i = 1; i <= 5; i++)
    {
        Task.Run(async () =>
        {
            await Task.Delay(1000);
            Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
        });
    }
    Console.ReadLine();

forループで実行されたTask内で、1秒待ってループカウンタ i をコンソール出力しているだけのコードのように見えますね。これを見て、皆さんはどういう実行結果を想像しますか?
Task End(1~5)が出力されるだけだろ?と思う人は結構いるかもしれません。しかし、実際は…?

実行結果
10:43:01.801 [Start for]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]
10:43:02.928 [Task End(6)]

なんてこった!全部Task End(6)になっています。一体どういう事なんだ…

何故ループカウンタを直接使用してはいけないのか

先程のサンプルコードで出力結果が全部Task End(6)になった理由は単純で、非同期Taskが実行されて、実際にコンソール出力するタイミングの時点で、ループカウンタは既に6までカウントアップされてループを終えているからです。ラムダ式外のループカウンタを直接使用した場合、いつの時点でのループカウンタの値が使用されているか判らないのです。

ちなみに、VB.NETで同等の処理を行った場合、ビルド時にwarningが発生します。C#でも出してくれてもいいのでは?という気はしないでもないです。
(BC42324より抜粋)

warning BC42324: ラムダ式内で繰り返し変数を使用すると、予期しない結果が発生する可能性があります。代わりに、ループ内にローカル変数を作成して繰り返し変数の値を割り当ててください。

今度はforループ内でローカル変数countを宣言してループカウンタを割り当て、その値をコンソール出力するようにしてみましょう。

ループ内ローカル変数使用版サンプル
   Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start for]");
    for (var i = 1; i <= 5; i++)
    {
        var count = i;
        Task.Run(async () =>
        {
            await Task.Delay(1000);
            Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({count})]");
        });
    }
    Console.ReadLine();
実行結果(ループ内ローカル変数使用)
11:17:34.883 [Start for]
11:17:36.028 [Task End(1)]
11:17:36.028 [Task End(4)]
11:17:36.028 [Task End(5)]
11:17:36.028 [Task End(3)]
11:17:36.028 [Task End(2)]

1~5がバラバラに出力されましたね。forループ内でローカル変数を宣言する事で、ループ毎に変数が確保され、ループカウンタの値が保持されています。ちなみに、実際にこの手の並列処理を行う場合は、Parallelクラスというものがあるので、そちらを利用した方がよいでしょう。

Parallel.For使用版サンプル
    Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start Parallel.For]");
    Parallel.For(1, 6, async (i) =>
    {
        await Task.Delay(1000);
        Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
    });
    Console.ReadLine();
実行結果(Parallel.For使用)
11:30:34.820 [Start Parallel.For]
11:30:35.966 [Task End(2)]
11:30:35.966 [Task End(1)]
11:30:35.966 [Task End(4)]
11:30:35.966 [Task End(3)]
11:30:35.967 [Task End(5)]

Task.Runをawaitすれば良いのでは?

そういう感じの回答もありましたが、単なる同期処理になりTask使う意味が無くなりますので、それならむしろTaskを使用しない方が良いでしょう。さて、実際にawaitさせてみましょう。

await使用版サンプル
    Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Start for]");
    for (var i = 1; i <= 5; i++)
    {
        await Task.Run(async () =>
        {
            await Task.Delay(1000);
            Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} [Task End({i})]");
        });
    }
    Console.ReadLine();
実行結果(await使用)
11:57:27.309 [Start for]
11:57:28.394 [Task End(1)]
11:57:29.403 [Task End(2)]
11:57:30.420 [Task End(3)]
11:57:31.427 [Task End(4)]
11:57:32.437 [Task End(5)]

Task End(1~5)が出力されていますが、普通に非同期で実行すれば1秒で終わる処理が順次同期実行しているので、全体で5秒掛かっています。

参考URL

ループをParallelクラスで並列処理にするには?[C#/VB]
データとタスクの並列化における注意点

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