create-react-app で掲示板アプリを開発して ASP.NET Core 上で動かす


## はじめに

ASP.NET Core で シングルページアプリケーションを開発する方法をググると、既存の ASP.NET Core の SPA プロジェクトテンプレートを使う方法と、 SPA と Web API を別々のドメインで提供して CORS するサンプルは出てきますが、`create-react-app`で開発したアプリを ASP.NET Core 上で同一ドメインで動かすサンプルが見つからなかったので自分でやってみることにしました。

## create-react-app

React アプリ開発をビルド環境構築なしに始められる facebook 公式のツールです。
これを使って掲示板の SPA を開発します。

## ASP.NET Core

クロスプラットフォームに対応した .NET の Web アプリケーションフレームワークです。
`create-react-app`で開発した掲示板アプリのクライアントへの配信と Web API の受付を行います。

## 開発した掲示板アプリ

![image.png](https://qiita-image-store.s3.amazonaws.com/0/195024/c49a04b3-c989-6b23-84ef-a502b52f85a9.png)

サーバーサイドはモックなしで、投稿を保存するデータベース、投稿一覧取得と新規投稿用の Web API まで作りました。
クライアントサイドは**[いまどきのJSプログラマーのための Node.jsとReactアプリケーション開発テクニック](https://www.amazon.co.jp/%E3%81%84%E3%81%BE%E3%81%A9%E3%81%8D%E3%81%AEJS%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9E%E3%83%BC%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE-Node-js%E3%81%A8React%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E9%96%8B%E7%99%BA%E3%83%86%E3%82%AF%E3%83%8B%E3%83%83%E3%82%AF-%E3%82%AF%E3%82%B8%E3%83%A9%E9%A3%9B%E8%A1%8C%E6%9C%BA/dp/4802611145)**を参考に開発しました。

## 開発環境構築

### サーバーサイド

ソリューションとプロジェクト名を別々にしたいので、Visual Studio でなく、 dotnet コマンドでプロジェクトを作成します。

```powershell
# .NET Core のバージョン
PS > dotnet --version
2.1.400
# 作業フォルダ
PS > mkdir BbsApp
PS > cd BbsApp
# ソリューションファイルの作成
PS > dotnet new sln
# API サーバーのプロジェクトを作成
PS > dotnet new webapi -o ./BbsServer
# プロジェクトをソリューションに追加
PS > dotnet sln add ./BbsServer
```

### `Startup.cs`の編集

HTML/CSS や JavaScript をクライアントサイドに提供できるようにします。
静的ファイルはデフォルトではプロジェクト直下の`wwwroot`フォルダに配置する規約があります。

`wwwroot`からクライアントへの静的ファイルを提供するには`BbsServer/Startup.cs`を以下のように編集します。

```diff
public void Configure(IApplicationBuilder app, IHostingEnvironment env) {
    if (env.IsDevelopment()) {
        app.UseDeveloperExceptionPage();
    } else {
        app.UseHsts();
    }

    app.UseHttpsRedirection();
    app.UseMvc();
+   app.UseStaticFiles();
}
```

### `launchsettings.json`の編集

デバッグでブラウザを立ち上げた際にデフォルトでアクセスする URL を設定します。
`launchsettings.json`の`launchUrl`を`index.html`に変更します。


```diff
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    略
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
-     "launchUrl": "api/values",
+     "launchUrl": "index.html",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "BbsServer": {
      略
    }
  }
}
```

## クライアントサイド

### create-react-app で react アプリケーションを作成

ソリューションファイル(`BbsApp.sln`)と同じディレクトリに React のプロジェクトを作ります。
（作る場所はどこでもいいのですが、`BbsServer`プロジェクトと同じフォルダ（`BbsServer.csproj`のあるf階層）に作成すると、`BbsServer` をVisual Studio でビルドする際にクライアントサイドもビルドしようとしてエラーを吐くので注意が必要です。）

```powershell
PS > cd BbsApp
# TypeScript が好きなので、--scripts-version=react-scripts-ts を指定しました
PS > create-react-app bbs-client --scripts-version=react-scripts-ts
```

### ビルドした静的ファイルの配置先を変更

React アプリを ASP.NET Core で動かしたいので、ビルドした静的ファイルの配置先を`BbsApp/BbsServer/wwwroot`に変更します。

```powershell
PS > cd bbs-client
#　アプリの設定を create-react-app の管理下から外す
PS > yarn eject
```

`eject`で`config/Path.js`が作成されるので、`appBuild`の値を書き換えます。
`BbsServer/wwwroot`を相対パスで指定します。

```diff
// config after eject: we're in ./config/
module.exports = {
  dotenv: resolveApp(".env"),
- appBuild: resolveApp("build"),
+ appBuild: resolveApp("../BbsServer/wwwroot"),
  /* 略 */
};
```

### React アプリのビルドと確認

React アプリをビルドして`wwwroot`に配置できているかを確認します。

```powershell
PS > yarn build
yarn run v1.7.0
$ node scripts/build.js
Creating an optimized production build...
Starting type checking and linting service...
Using 1 worker with 2048MB memory limit
ts-loader: Using typescript@3.0.1 and C:\Users\sa500\Desktop\qiita-article\BbsApp\bbs-client\tsconfig.prod.json
Compiled successfully.

File sizes after gzip:

  36.07 KB  wwwroot\static\js\main.70f62b72.js
  300 B     wwwroot\static\css\main.29266132.css

The project was built assuming it is hosted at the server root.
You can control this with the homepage field in your package.json.
For example, add this to build it for GitHub Pages:

  "homepage" : "http://myname.github.io/myapp",

The ..\BbsServer\wwwroot folder is ready to be deployed.
You may serve it with a static server:

  yarn global add serve
  serve -s ..\BbsServer\wwwroot

Find out more about deployment here:

  http://bit.ly/2vY88Kr

Done in 7.47s.
```

Visual Studio で`BbsApp.sln`を開くと、ソリューションエクスプローラーで`wwwroot`フォルダにビルドした出力が配置されていることが確認できます。

<img src="https://qiita-image-store.s3.amazonaws.com/0/195024/957e1ded-70e2-63da-53b7-49fdc3b199d1.png" width=320>

Visual Studio でデバッグすると`https://localhost:<port#>/index.html`にアクセスされ、`wwwroot/index.html`が配信されるのでブラウザに以下の画面が表示されます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/195024/0c8ff3e1-3c16-ae6e-4805-3e7e4e646429.png)

## サーバーサイドの開発

### エンティティの作成

投稿内容を表すエンティティを作成します。

`BbsServer`プロジェクトに`Models`フォルダを追加します。
`Models`フォルダに`PostItem.cs`を追加します。
モデルは DB のレコード、各プロパティはカラムのイメージです。
`Id`は「設定より規約」で自動的に主キーになります。
`[Required]`属性をつけると必須（DBでは`not null`）になります。

```c#
using System;
using System.ComponentModel.DataAnnotations;

namespace BbsServer.Models {
    public class PostItem {
        // プライマリーキー
        public int Id { get; set; }
        // 投稿者名
        [Required]
        public string Name { get; set; }
        // 本文
        [Required]
        public string Body { get; set; }
        // 投稿日時
        [Required]
        public DateTime PostDateTime { get; set; }
    }
}
```

### データベースコンテキストの作成

アプリからデータベースへのアクセスはデータベースコンテキストを使って行います。
`Models`フォルダに`BbsContext.cs`を作成します。
データベースコンテキストは`Microsoft.EntityFrameworkCore.DbContext`を継承します。
イメージとしては`BbsContext`がスキーマ、`DbSet<PostItem> PostItems`がテーブルです。

```c#
using Microsoft.EntityFrameworkCore;

namespace BbsServer.Models {
    public class BbsContext : DbContext {
        public BbsContext(DbContextOptions<BbsContext> options)
            : base(options) { }

        public DbSet<PostItem> PostItems { get; set; }
    }
}
```

### DI コンテナーに データベースコンテキストを登録する。

ASP.NET Core には標準で DI コンテナーが組み込まれています。
DI コンテナーに`BbsContext`を登録すると、コンストラクタの引数に`BbsContext`を持つクラスに自動的に`BbsContext`のインスタンスが渡されます。

また、データベースは`SQL Server localdb`（ローカルで動く簡易データベース）を使うことにします。
`IConfiguration.GetConnectionString`で`appsetting.josn`から接続文字列を取得するように設定します。
接続名はコンテキスト名と同じ`BbsContext`とします。

`Startup.cs`を以下のように編集します。

```diff
+   using BbsServer.Models;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.Mvc;
+   using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;

    namespace BbsServer {
        public class Startup {
            /* 略 */
            public void ConfigureServices(IServiceCollection services) {
                services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
+               services.AddDbContext<BbsContext>(options => {
+                   options.UseSqlServer(Configuration.GetConnectionString(nameof(BbsContext)));
+               });
            }
            /* 略 */
        }
    }
```

## データベース接続文字列の設定

`appsettings.json`から接続文字列を読み取るようにしたので、接続文字列を追加します。
`Startup.cs`で設定した通り、`SQL Server localdb`を使用、接続名は`BbsContext`、スキーマも`BbsContext`とします。

```json
{
  "Logging": {
    略
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "BbsContext": "Server=(localdb)\\mssqllocaldb;Database=BbsContext;Trusted_Connection=True;MultipleActiveResultSets=true"
  }
}
```

## データベースのマイグレーション

まず、マイグレーションファイルを作成します。
マイグレーションファイルは データベースの設計図です。
マイグレーションファイルを実行すると、その内容でデータベースを生成したり、変更を適用してくれます。

PMC（パッケージマネージャーコンソール）に以下のコマンドを入力します。
最初なのでマイグレーション名は`InitialCreate`としました。

```powershell
PM> Add-Migration InitialCreate
Microsoft.EntityFrameworkCore.Infrastructure[10403]
      Entity Framework Core 2.1.1-rtm-30846 initialized 'BbsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options: None
To undo this action, use Remove-Migration.
```

プロジェクトに`Migrations`フォルダが追加され、3つのファイルが作成されます。
ファイルの内容については [移行 - EF Core | Microsoft Docs](https://docs.microsoft.com/ja-jp/ef/core/managing-schemas/migrations/) に詳しく載っています。

実際にデータベースを作成します。
`Update-Database`コマンドを入力します。

```powershell
PM> Update-Database
# 略
Applying migration '20180818034025_InitialCreate'.
Microsoft.EntityFrameworkCore.Migrations[20402]
      Applying migration '20180818034025_InitialCreate'.
Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      CREATE TABLE [PostItems] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NOT NULL,
          [Body] nvarchar(max) NOT NULL,
          [PostDateTime] datetime2 NOT NULL,
          CONSTRAINT [PK_PostItems] PRIMARY KEY ([Id])
      );
# 略
Done.
```

`Create table`が実行されデータベースが作成されました。

`表示 > SQL Server オブジェクトエクスプローラー`から確認すると実際に DB が作成されています。
![dbo.postitems.PNG](https://qiita-image-store.s3.amazonaws.com/0/195024/9d39d27f-88d5-8fcf-dd86-8dbec1462f85.png)

## コントローラーの作成

Http リクエストを受け付けるためコントローラーを作成します。

`Contorollers`フォルダの`valuesController.cs`は不要なので削除します。
`Contorollers`フォルダに`BbsController.cs`を作成します。
コントローラーは`Microsoft.AspNetCore.Mvc.ControllerBase`を継承します。
※普通にファイルを追加するのと変わりませんがスキャフォールディングすることもできます。
※`Contorollersを右クリック > 追加 > コントローラー > API コントローラー -空`を選択します。

`[Route("api/[controller]")]`属性で WebAPI の URL を指定します。
`[controller]`は自動的にコントローラー名が割り当てられます。
今回の場合、`https://localhost:<port#>/api/bbs`でアクセスできます。

```c#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace BbsServer.Controllers {
    [Route("api/[controller]")]
    [ApiController]
    public class BbsController : ControllerBase {
    }
}
```

データベースにアクセスするために、コンストラクタを作成し、引数に`BbsContext`を追加します。
DI コンテナーから`BbsContext`のインスタンスが自動で渡されます。

```c#
using BbsServer.Models;
using Microsoft.AspNetCore.Mvc;

namespace BbsServer.Controllers {
    [Route("api/[controller]")]
    [ApiController]
    public class BbsController : ControllerBase
    {
        // コンストラクタを通じて受け取った BbsContext を保持
        BbsContext BbsContext { get; }

        public BbsController(BbsContext bbsContext) {
            BbsContext = bbsContext;
        }
        /*
         * コントローラーから直接 DB にアクセスするのは良くない設計なので、
         * 本来はサービスクラスを作成してそれを呼んだ方が良い。
         * 大規模なものならリポジトリークラスを作成しデータベースへのアクセスは
         * Controller -> Service -> Repository -> DB のように行う
         */
    }
}
```

投稿一覧を取得する`Get`メソッドと新しく投稿する`Post`メソッドを追加します。
`[HttpGet]` `[HttpPost]`属性をつけることで、受け付ける Http リクエストメソッドを指定できます。

投稿一覧取得`Get`は先に投稿されたものから順に（投稿日時の昇順）で取得できるようにします。

新規投稿`Post`はサーバー側の投稿日時を設定し DB に保存します。
その際、`Id`は自動で採番（インクリメント）されます。

```c#
using BbsServer.Models;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;

namespace BbsServer.Controllers {
    [Route("api/[controller]")]
    [ApiController]
    public class BbsController : ControllerBase {
        BbsContext BbsContext { get; }

        public BbsController(BbsContext bbsContext) {
            BbsContext = bbsContext;
        }

        // GET api/bbs
        [HttpGet]
        public ActionResult<IEnumerable<PostItem>> Get() {
            var postItems = BbsContext.PostItems
                .OrderBy(postItem => postItem.PostDateTime)
                .ToList(); // ToList でクエリが実行されるため ToList は必要
            return postItems;
        }

        // POST api/bbs
        [HttpPost]
        public ActionResult<PostItem> Post([FromBody] PostItem postItem) {
            postItem.PostDateTime = DateTime.Now;
            BbsContext.PostItems.Add(postItem);
            BbsContext.SaveChanges();
            return postItem;
        }
    }
}
```

Web API を実際に使ってみます。Visual Stduio でデバック実行しサーバーを立ち上げます。
クライアントサイドはまだできていないので [Postman](https://www.getpostman.com/) で Http リクエストを送ってみます。

```
・POSTMAN の設定と入力内容
Http メソッド : POST
URL : https://localhost:<port#>/api/bbs
Body : { "name": "nossa", "body": "はじめての投稿！" }
```

![first_post.PNG](https://qiita-image-store.s3.amazonaws.com/0/195024/c400fa9c-f51a-b997-07c2-04fd93e08fe6.png)

投稿内容がレスポンスとして帰ってきているのが確認できます。

2回目の投稿で`Id`がインクリメントされているのが確認できます。
![second_post.PNG](https://qiita-image-store.s3.amazonaws.com/0/195024/4976808d-e592-20cb-7086-5a64ab8e43b7.png)

投稿一覧も取得できました。投稿日時の昇順になっています。
![get.PNG](https://qiita-image-store.s3.amazonaws.com/0/195024/b39c9f1c-0970-29a5-1591-23442770eabb.png)


## クライアントサイドの開発

React の開発は VSCode で行いました。

### ユーザー定義型の宣言

先にこのアプリで使うユーザー定義型を宣言しておきます。
`src`直下に`types.ts`を作成します。

```ts:src/types.ts
// 投稿内容
export interface IPostItem {
  id: number;
  name: string;
  body: string;
}
// 書き込みフォームの State
export interface IBbsFormState {
  name: IPostItem["name"];
  body: IPostItem["body"];
}
// 書き込みフォームの Props
export interface IBbsFormProps {
  onPost: () => void;
}
// アプリの state
export interface IBbsAppState {
  items: IPostItem[];
}
```

## スタイルの定義

スタイルも先に定義しておきます。
`textAlign`の値は`center` `right`などに固定されているので、TypeScript で 指定する場合、キャストが必要です。

```ts:src/styles.ts
const styles = {
  form: {
    backgroundColor: "#F0F0F0",
    border: "1px solid silver",
    padding: 12
  },
  h1: {
    backgroundColor: "blue",
    color: "white",
    fontSize: 60,
    padding: 12
  },
  right: {
    // textAlign の値の文字列はキャストしておかないと tsx のstyle に渡せずコンパイルが通りません。
    textAlign: "right" as "right"
  }
};
export default styles;
```

## 書き込みフォームの作成

書き込みフォームを作成します。
`src`直下に`BbsForm.tsx`を作成します。

```tsx
import * as React from "react";
import styles from "./styles";
import { IBbsFormProps, IBbsFormState } from "./types";

class BbsForm extends React.Component<IBbsFormProps, IBbsFormState> {
  constructor(props: IBbsFormProps) {
    super(props);
    this.state = { body: "", name: "" };
    this.nameChanged = this.nameChanged.bind(this);
    this.bodyChanged = this.bodyChanged.bind(this);
    this.post = this.post.bind(this);
  }

  // 名前が変更された時、state に反映する
  public nameChanged(e: React.FormEvent<HTMLInputElement>) {
    this.setState({ name: (e.target as HTMLInputElement).value });
  }

  // 本文が変更された時、state に反映する
  public bodyChanged(e: React.FormEvent<HTMLInputElement>) {
    this.setState({ body: (e.target as HTMLInputElement).value });
  }

  // 投稿処理。投稿後、コールバック this.props.onPost を実行する。
  public async post() {
    const headers = {
      Accept: "application/json",
      "Content-Type": "application/json"
    };
    const body = JSON.stringify(this.state);
    await fetch("api/bbs", {
      method: "POST",
      headers,
      body,
    });
    this.props.onPost();
  }

  public render() {
    return (
      <div style={styles.form}>
        名前: <br />
        <input type="text" value={this.state.name} onChange={this.nameChanged} />
        <br />
        本文: <br />
        <input type="text" value={this.state.body} size={60} onChange={this.bodyChanged} />
        <button onClick={this.post}>発言</button>
      </div>
    );
  }
}
export default BbsForm;
```

## アプリの本体の作成

アプリの本体を作成します。

```tsx:src/BbsApp.tsx
import * as React from "react";
import BbsForm from "./BbsForm";
import styles from "./styles";
import { IBbsAppState, IPostItem } from "./types";

class BbsApp extends React.Component<{}, IBbsAppState> {
  constructor(props: {}) {
    super(props);
    this.state = {
      items: []
    };
    this.GetPostItems = this.GetPostItems.bind(this);
  }

  // コンポーネント描画後、投稿内容一覧を取得
  public async componentDidMount() {
    await this.GetPostItems();
  }

  // 投稿内容の一覧を取得し、state に反映
  public async GetPostItems() {
    const response: Response = await fetch("api/bbs", {
      method: "GET"
    });
    if (response.status === 200) {
      const postItems: IPostItem[] = await response.json();
      this.setState({ items: postItems });
    }
  }

  public render() {
    // 投稿内容一覧の tsx を作成
    const items = this.state.items.map(item => (
      <li key={item.id}>
        {item.name} - {item.body}
      </li>
    ));
    return (
      <div>
        <h1 style={styles.h1}>掲示板</h1>
        {/* コールバックとして投稿内容一覧取得処理を渡す */}
        <BbsForm onPost={this.GetPostItems} />
        <p style={styles.right}>
          <button onClick={this.GetPostItems}>再読込</button>
        </p>
        <ul>{items}</ul>
      </div>
    );
  }
}
export default BbsApp;
```

続いて、`src/index.tsx`の`ReactDom.Render`する tsx を`<App>`から`<BbsApp>`に変更します。

## 掲示板アプリを使ってみる

まず、React アプリを`wwwroot`に配置します。
環境構築でビルドした出力先を`wwwroot`に変更したので以下のコマンドを入力すれば OK です。


```powershell
PS > yarn build
```

Visual Studio を立ち上げてデバッグ実行します。
名前と本文を入れて発言ボタンを押すと投稿されます。
投稿後は画面の投稿一覧が再表示されます。
一覧表示から再表示まで他の人の投稿があった場合、それも反映されます。

最後にもう一度スクリーンショットを張っておきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/195024/26713094-ceda-b40c-fa0c-8d81ea75dc55.png)