画面に表示されるフロントエンドにReactやTypeScriptを使い、裏側でデータベースとのアクセスを行うバックエンドにC#.NETを使ったシステム開発の概要を記載します。
流れとしては、まずはリクエストに応じて結果を返すAPIをC#.NETで開発します。そして、Reactで作ったページからそれらを呼び出して、データベースの情報を画面に表示したり、画面で入力した情報をデータベースに保存します。
- 間違っている可能性もありますので自己責任で参考にしてください。
- ReactやC#.NETの基本的な部分などは省略しています。
Visual Studioでプロジェクトを作成
まず最初に、Visual Studioを立ち上げて、React+.NET Coreのテンプレートを使ったプロジェクトを新規作成します。
プロジェクトファイルを右クリックして「NuGetパッケージの管理」を選択します。 そして、NuGetパッケージマネージャーを使用して必要なライブラリをインストールします。
Microsoft.TypeScript.MSBuild | TypeScript(静的型付けが可能なJavaScript)を使う場合 |
Microsoft.EntityFrameworkCore | Entity Framework (SQLを書かなくてもクエリを実行できるORマッパー)を使う場合 |
Microsoft.EntityFrameworkCore.Tools | Entity Framework 使う場合 。パッケージマネージャーコンソールでマイグレーションなどのコマンドを実行するために必要 |
Microsoft.EntityFrameworkCore.SqlServer | データベースにSQL Serverを使う場合 |
データベースの設定
ここからはデータベースの設定に入ります。データベースに接続するための情報を設定したり、テーブルやカラムを作成します。
接続文字列の設定
プロジェクトファイルを右クリックして「ユーザーシークレットの管理」を選択します。
JSONのファイルが開くので、以下のような感じでデータベースの接続文字列を記述します。ここではSQL Serverを使っていますが、ご自身の環境に応じて変更してください。
{
"ConnectionStrings:DbConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Test;Integrated Security=True;"
}
接続文字列はユーザーシークレットを使わなくても記述できますが、ユーザーシークレットを使うことで開発時のセキュリティが向上する可能性があります。
ユーザーシークレットを使って作成したJSONファイルは、プロジェクトとは別フォルダに格納されます。そのため、GitHubなどを使ってソースコードを管理している場合に、ユーザーシークレットのファイルが自動的に除外されます。
テーブルに対応するクラスを作成
データベースのテーブルと一対一になるクラスを作成します。後述するマイグレーションという操作をすると、ここで作成したクラスの内容に従ってテーブルが作られます。
以下は、ユーザー情報を管理するクラスのサンプルです。
public class User
{
[Key]
public int Id { get; set; }
[Display(Name = "名前")]
[Required(ErrorMessage = "名前を入力してください。")]
[MaxLength(50, ErrorMessage = "名前は50文字以内で入力してください。")]
public string Name { get; set; }
[Display(Name = "年齢")]
[Required(ErrorMessage = "年齢を入力してください。")]
[Range(1, 100, ErrorMessage = "年齢は1~100の半角数字を入力してください。")]
public string Age { get; set; }
[Display(Name = "性別")]
[Required(ErrorMessage = "性別を選択してください。")]
[Range(1, 2, ErrorMessage = "不正な性別が選択されています。")]
public string Gender { get; set; }
}
プロパティに属性を付与すると入力検証が自動で行われて、場合によってはカラムに制約がつきます。Required属性は必須入力チェック、Range属性は範囲チェックです。
「Id」という特別なプロパティ名を使うと、主キーとして認識されます。ただし、ここでは保守性を考えて分かりやすいように、Key属性をあえて付与しています。
同時実行制御を行う場合はTimestampのプロパティが必要かもしれません。
DbContextを作成
Entity Frameworkを使ってデータベースにアクセスするためには、DbContextクラスを継承したデータベースコンテキストを作成します。
public class MyContext : DbContext
{
public MyContext(DbContextOptions<MyContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
}
データベースコンテキストには、先ほど作成したテーブルのクラスを型にしたDbSetのプロパティ(Users)を用意します。
マイグレーション
マイグレーションを行うと、 テーブル内のデータを保持したまま、データベースコンテキストをもとにしたテーブルの作成や変更が可能になります。
「ツール」⇒「NuGetパッケージマネージャー」⇒「パッケージマネージャーコンソール」を立ち上げて、以下のコマンドを実行します。
Enable-Migrations
Add-Migration InitialCreate
Update-Database
Enable-Migrationsは初回のみ実行します。Add-MigrationとUpdate-Databaseに関しては、データベース(データベースコンテキストおよびテーブルのクラス)を変更するたびに毎回実行します。
Add-Migrationは、現在のデータベースとクラスの定義を比較して、差分のファイルを作成します。
例えば、User.csに婚姻状況を登録する「Marriage」というプロパティを追加してAdd-Migrationを実行すると、Migrationsフォルダの中に、以下のような処理を記述したファイルが作成されます。
migrationBuilder.AddColumn<int>(
name: "Marriage",
table: "Users",
type: "int",
nullable: false,
defaultValue: 0);
InitialCreateの部分は初回のテーブル作成なのでこのような文言になっていますが、変更を表す任意の名前をつけてください。例えば、婚姻状況の例では以下のような感じかと思います。
Add-Migration UsersテーブルにMarriageを追加
Update-Databaseは、Add-Migrationで作成された差分ファイルをもとにデータベースを更新します。
サービスの作成
ビジネスロジックやデータベースアクセスなどの処理を記述するサービスを作成します。以下は、ユーザー情報のサービスクラスのサンプルです。
public class UserService
{
private readonly MyContext _context;
public UserService(MyContext context)
{
_context = context;
}
public List<User> GetUserList()
{
return _context.Users.AsNoTracking().ToList();
}
}
GetUserListというメソッドは、データベースコンテキストを経由して、Usersテーブルからユーザー情報の一覧を取得しています。
AsNoTrackingを使うとEntity Frameworkによってクエリが追跡されません。そのため、更新処理では使えませんが、単にデータを取得するような場合はパフォーマンスが向上すると言われています。
ポイントは、コンストラクタの引数でデータベースコンテキストのオブジェクトを受け取り、それをUserServiceクラスのフィールドに設定していることです。これは「コンストラクタインジェクション」というDI(Dependency Injection)のパターンです。
コンストラクタインジェクションを使わない場合、一般的には以下のようにデータベースアクセスの処理を記述します。
public class UserService
{
public List<User> GetUserList()
{
using (var context = new MyContext())
{
return context.Users.AsNoTracking().ToList();
}
}
}
上記のように、データベースにアクセスするたびにusingステートメントを使ってMyContextのインスタンスを生成し、破棄する必要があります。
一方でコンストラクタインジェクションは、コンストラクタの中に一度だけ記述すれば済みますし、破棄の管理も自動で行ってくれます。他にもメリットはありますが、ここでは割愛します。
コンストラクタインジェクションを利用するためには、Startup.csというファイルのConfigureServicesメソッドにて、 データベースコンテキストをDIコンテナに登録する必要があります。
services.AddDbContext<MyContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DbConnection"),
providerOptions => providerOptions.CommandTimeout(120)));
コントローラーの作成
コントローラーは、React(JavaScript)からリクエストを受け取るAPIとなる部分です。
例えば、ユーザー情報の編集画面において更新ボタンをクリックした場合、コントローラーにリクエストを飛ばし、コントローラーがサービスのメソッドを実行してデータベースに登録するという流れです。
以下は、ユーザー情報の一覧を返すメソッドを定義したコントローラーのサンプルです。
[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
private readonly UserService _userService;
public UserController(UserService userService)
{
_userService = userService;
}
[HttpGet]
public List<User> GetUserList()
{
return _userService.GetUserList();
}
}
APIとしてコントローラーを利用する場合はControllerBaseクラスを継承します。一般的な.NETのMVCではControllerクラスを継承しますが、APIの場合はControllerBaseクラスが推奨されています。
また、コントローラーにはApiController属性を付与します。それによって、Route属性などAPIに役立つ機能を利用できるようになります。
Route属性はルーティングをコントールします。「api/[controller]」のように記述した場合は、コントローラー名に置換されて「api/user」というURLがベースになります。
サービスと同様に、コントローラーではサービスクラスをコンストラクタでインジェクションしています。Startup.csのConfigureServicesメソッドにて、サービスをDIコンテナに登録してください。
services.AddTransient<UserService>();
インスタンスを生成するタイミングが異なるAddSingletonとAddScopedというメソッドもありますが、ここでは割愛します。また、サービスクラスではなく、インターフェースを登録することも可能です。
ReactとAPIの連携
これまで作成したコントローラーのAPIをReactから呼び出します。
Visual Studioでプロジェクトを作成した際にClientAppフォルダが生成されるので、そこにファイルを作成してください。
以下は、ユーザー情報の一覧を取得するサンプルです。
const [userList, setUserList] = useState<UserList>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
getUserList();
}, []);
const getUserList = () => {
setLoading(true);
fetch("api/user")
.then((response) => response.json())
.then((data) => {
setUserList(data);
})
.finally(() => {
setLoading(false);
});
};
useEffectの第二引数に空の配列を渡すことで、初回マウント時のみにgetUserListを実行します。
getUserListでは、ローディング画像を表示させた後、JavaScriptのFetch APIを使ってコントローラーを呼び出します。そして、取得したユーザー一覧をsetUserListでstateに保存しています。
fetchメソッドはオプションでHTTPメソッド(GET、POST、PUT、DELETEなど)を指定できますが、デフォルトでは「GET」になります。つまり、ここでは「api/user」というURLに対してGETメソッドでリクエストしています。
UserControllerのGetUserListにはGETメソッドに対応するHttpGet属性を付与しているため、そちらが実行されます。
このように、属性を使って呼ばれるメソッドを判別する方法を「属性ルーティング」と呼び、.NET CoreでAPIを作成する場合には推奨されています。
更新処理と入力検証
これまでの内容で、一通りの流れはできたかと思います。ReactからC#.NETで作成したAPIを呼び出し、データベースから取得した情報をReactに返すところまでやりました。
最後に、応用として入力検証を伴うユーザー情報の更新にチャレンジします。
まずは、ユーザー情報の更新をデータベースに反映させるサービスのサンプルです。
public ServiceResponseModel<object> UpdateUser(User user)
{
var response = new ServiceResponseModel<object>();
_context.Entry(user).State = EntityState.Modified;
try
{
_context.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
response.Result = ServiceResult.Error;
if (!IsUserExists(user.Id))
{
response.ErrorType = ErrorType.NotFound;
}
else
{
response.ErrorType = ErrorType.Concurrency;
}
return response;
}
return response;
}
private bool IsUserExists(int id)
{
return _context.Users.AsNoTracking().Any(u => u.Id == id);
}
ServiceResponseModelは処理結果を格納する独自のジェネリッククラスで、処理が成功か失敗か、失敗の場合はどのようなエラーかといった情報を保持します。戻り値をどうするかは色々な方法が考えられますので、自由に変えてください。
Entryメソッドを使用してデータベースコンテキストに更新後のユーザー情報を渡して、状態を更新(EntityState.Modified)に設定します。
その後、SaveChangesメソッドを使用してデータベースに反映させます。
この際、DbUpdateConcurrencyExceptionの例外をキャッチします。IsUserExistsメソッドで更新対象のユーザーが存在するかチェックを行い、既に削除されている場合は「ErrorType.NotFound」、変更されている場合は「ErrorType.Concurrency」という独自のEnumを設定します。
次に、コントローラーの作成を行います。
[HttpPut("{id}")]
public IActionResult UpdateUser(int id, User user)
{
if (id != user.Id)
{
return BadRequest();
}
var response = _userService.UpdateUser(user);
if (response.Result == ServiceResult.Error)
{
switch (response.ErrorType)
{
case ErrorType.NotFound:
return NotFound();
}
}
return NoContent();
}
更新処理の場合は、「PUT」というHTTPメソッドでリクエストを送ることが一般的です。そのため、コントローラーのメソッドにはHttpPut属性を付与します。
HttpPut属性に「{id}」というパラメータがありますが、これを指定することでidが付いたURLのリクエストをマッピングできます。例えば「api/user/6」というURLにPUTでリクエストを送ると、上記のUpdateUserメソッドが呼ばれ、引数でidを受け取ることもできます。
.NETのモデルバインディングによって、UpdateUserメソッドの引数にある「user」には画面で入力した更新後のユーザー情報が格納されています。それをサービスのUpdateUserメソッドに渡して、データベースに反映させています。
サービスの戻り値がエラーの場合は、対応するエラーを返しています。ここでは、削除済みの場合だけNotFoundメソッドの戻り値を返して、それ以外は省略しています。
コントローラーの引数に設定したモデルのデータ型(C#のintやstring)と、リクエストで渡すオブジェクトのデータ型(TypeScriptのnumberやstring)が同じでないと、メソッドが見つからずに不正なリクエストになる可能性があります。例えば、C#のAgeプロパティがintで、TypeScriptのageがstringといったケースです。
最後に、ReactからAPIを呼び出す部分のサンプルです。
const updateUser = () => {
fetch(`api/user/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
})
.then(async (response) => {
if (response.ok) {
history.push("/");
} else {
const data = await response.json();
// Not Found
if (data.status === 404) {
history.push({
pathname: "/",
state: { outerError: "対象のユーザーは存在しません。" },
});
}
// 入力エラー
const errorList: string[] = [];
for (const [key, value] of Object.entries(data.errors)) {
if ([value].length > 0) {
Array.prototype.push.apply(errorList, [value]);
}
}
setErrors(errorList);
}
}})
.catch(() => {
// システムエラー
history.push("/error");
});
};
const onClickUpdate = () => {
updateUser();
};
ポイントは、HTTPメソッドに「PUT」を指定することです。コントローラーのメソッドにつけた属性が「HttpPut」なので、それに合わせる必要があります。
入力検証は、User.csを作成した際に「Required」や「MaxLength」といった属性をつければ、自動で検証を行ってくれます。上記の例では、エラーがあった場合はsetErrorsでエラーメッセージをstateに保存します。
入力検証以外の特殊なエラーに関しては、HTTPのステータスコードで判別しています。
NotFoundメソッドが実行された際は404のステータスコードが設定されるため、その場合はhistory.pushを使ってトップページに「対象のユーザーは存在しません。」というエラーメッセージを表示しています。