React と ASP.NET Core (C#) でホロジュール Web アプリをつくってみた

## はじめに

苦手な JavaScript ともっと仲良くしたいので、これまで開発してきたホロジュール Android アプリの Web アプリ版を作ってみることにしました。
このラプちゃんやルイ姐やみこちのような感じで、日付ごとに顔写真やサムネイルや配信時刻などを表示してみます。

![img00.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/8b0195c7-9764-8ae6-c360-116cbbcb361c.jpeg)

## JavaScript のカチッとしていない感じが苦手

プロトタイプに基づいたオブジェクトベースの言語？ということもあってか、C++ や C# や Java などのクラスベースのオブジェクト指向言語ばかり利用している自分にとっては、いつまでも違和感が抜けず、スコープの広さや範囲、動的で弱い型付けの変数や比較なども分かりにくいと感じています。

## JavaScript 使えないといかんでしょ

とはいえ、いつまでも JavaScript が苦手と言っていられないぐらい、フロントエンドからバックエンドまで活用されている言語ですし、ES5 とかであれば、クラスベースのオブジェクト指向言語っぽい雰囲気で開発が行えるので利用してみました。

## React を使ってみる

JavaScript という言語だけ学習してもモチベーションが続かないので、せっかくであれば、何かしらライブラリを活用してアプリを作ってみようと思い、過去に Angular や Vue は少しだけ触っているので、今回は React を利用してみることにしました。

## React は難しい？

React の「[公式サイトのチュートリアル](https://ja.reactjs.org/tutorial/tutorial.html)」を進めてみたり、秀和システムの書籍「[React.js&Next.js超入門 第2版](https://www.shuwasystem.co.jp/book/9784798063980.html)」を読みながら進めていたのですが、コンポーネントをどのような粒度で分けるのか、処理に必要なデータをどのように渡していくのか、どのタイミングで状態を持つのかなど、「コンポーネント」と呼ばれる小さく独立した部品から組み立てるという React の特徴になじめず、学習のペースも落ちてしまいました。

## React いけるかも！

学習が思うように進まない状態がしばらく続きましたが、これではいけないと思って React の公式サイトを眺めていたときに、[「React の流儀」](https://ja.reactjs.org/docs/thinking-in-react.html)というページを見つけました。

> React のすばらしい特長がいくつもありますが、あなたがどんなアプリを作ろうかと考えたことが、そのままアプリの作り方になる、というのはそのひとつです。本ドキュメントでは、検索可能な商品データ表を React で作っていく様子をお見せしましょう。

考えるよりやってみようということで、このチュートリアルを進めてみたのですが、これが自分にとっての転機になったと思います。

コンポーネントを階層構造に落とし込み、まずは状態を考えずにモックを作成して、情報を繋げていくという進め方で、 React の基本的な特徴や考え方をある程度つかむことができました。

* Step 1 UIをコンポーネントの階層構造に落とし込む
* Step 2 Reactで静的なバージョンを作成する
* Step 3 UI 状態を表現する必要かつ十分な state を決定する
* Step 4 state をどこに配置するべきなのかを明確にする
* Step 5 逆方向のデータフローを追加する

Step 2 まで読み進めたときには、自分が作ろうとしているアプリのモックがほぼ完成したため、これはいけるかも！と思い、必要なデータを既存の Web API から取得してみました。

## ハマったこと１（CORSエラー）

試しに、fetch を利用して、下記のように Web API からデータを取得してみたところ、オリジン間リソース共有 (CORS) のエラー「cors policy no 'access-control-allow-origin'」が発生しました。

```javascript
const requestOptions = {
    method: 'GET',
    headers: { 'Content-Type': 'application/json' },
};
fetch(url, requestOptions).then(response => {
    if (!response.ok) {
        throw new Error('Network response was not ok');
    }
    return response.json();
}).then(json => {
    console.log(json);
}).catch(error => {
    console.error(error);
});
```

> オリジン間リソース共有 (Cross-Origin Resource Sharing, CORS) は、追加の HTTP ヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにある選択されたリソースへのアクセス権を与えるようブラウザーに指示するための仕組みです。ウェブアプリケーションは、自分とは異なるオリジン (ドメイン、プロトコル、ポート番号) にあるリソースをリクエストするとき、オリジン間 HTTP リクエストを実行します。

これは React とは関係ありませんが、今回開発している React アプリと接続先の Web API のオリジンが異なり、Web API 側で異なるオリジンからのアクセスを許可していなかったことが原因でした。

Web API 側は Python で Flask を利用して開発していたので、下記のように flask_cors をインポートし、Web API のレスポンスヘッダーに Access-Control-Allow-Origin 等を含めることで、異なるオリジンからのアクセスをとりあえず許可しました。

```python
from flask_cors import CORS
...
app = Flask(__name__)
CORS(app)
...
@app.after_request
def after_request(response):
    response.headers.add('Access-Control-Allow-Origin', '*')
    response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
    response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS')
    return response
```

## ハマったこと２（機密情報の管理）

バージョン管理の対象に含めたくないということもあり、Web API の接続情報などを環境変数で管理しようとしたのですが、React はブラウザ側で動作するため、これらの情報が実行時に丸見えになるのではないかと気付き、どのように管理するべきか悩みました。

Create React App のドキュメントにも下記のような注意書きがあります。

> WARNING: Do not store any secrets (such as private API keys) in your React app!  
> Environment variables are embedded into the build, meaning anyone can view them by inspecting your app's files.

結局はバックエンド側で管理するしかないという結論に至り、React からバックエンドの Web API にリクエストを送り、バックエンドの Web API が管理する接続情報などを用いて、本来の Web API を呼び出してその結果を利用する流れとしました。

React → ASP.NET Core Web API → 認証 → Flask(Python) Web API

バックエンドの開発が必要になったため、[ASP.NET Core で React プロジェクトテンプレート](https://docs.microsoft.com/ja-jp/aspnet/core/client-side/spa/react?view=aspnetcore-6.0&tabs=visual-studio)を使用し、フロントエンドは React、バックエンドは ASP.NET Core という構成にしています。

開発中ということもあり、バックエンドの ASP.NET Core では、シークレットマネージャーを使用して接続情報などを管理し、Services.AddTransient を利用して Web API コントローラーへ依存関係を挿入しました。

```csharp
// Startup 側
public void ConfigureServices(IServiceCollection services)
{
    var userName = Configuration["HoloduleWeb:UserName"];
    var password = Configuration["HoloduleWeb:Password"];
    var baseUrl = Configuration["HoloduleWeb:BaseUrl"];
    services.AddTransient<IDataService>(_ => new DataService(userName, password, baseUrl));
    services.AddControllersWithViews();
    services.AddSpaStaticFiles(configuration =>
    {
        configuration.RootPath = "ClientApp/build";
    });
}

// Controller 側
public AccessTokenController(ILogger<AccessTokenController> logger, IDataService dataService)
{
    _logger = logger;
    _dataService = dataService;
}
```

## ハマったこと３（無限ループによるスタックオーバーフロー）

React のライフサイクルメソッドについて確認し、配置するコンポーネントが DOM として描画されるタイミング (componentDidMount()) を初期化時として Web API を呼び出して必要な情報を初期化し、更新が行われたタイミング (componentDidUpdate) を更新時として Web API を呼び出して必要な情報を更新すればよいと安易に考えて実装を行いました。

[react-lifecycle-methods-diagram](https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/)
![img01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/146b9bc0-48ef-72b1-01fa-0d66563684c7.jpeg)

Web API を呼び出す処理では、取得した結果をコンポーネントの描画に利用するために、状態を更新 (setState) していたのですが、その結果、下記のように無限ループとなりスタックオーバーフローが発生してしまったようです。

render() → componentDidMount() → setState() → render() → componentDidUpdate() → setState() → render() → componentDidUpdate() → setState() → 無限ループ...

状態を更新 (setState) すると、描画 (render) が行われるため、render が無限ループするような setState を行わないこと、特に fetch のような非同期処理と組み合わせる場合は、非同期処理が実行中か完了しているかを切り分け、完了した際に setState を行い、必要な情報が正しくコンポーネントに描画されるようになることを意識して修正してみました。

また、データの取得を親コンポーネント側に寄せるため、子コンポーネントでの操作は、親コンポーネントから props を通して渡したメソッドを利用し、結果としてすべての setState を親コンポーネントで行うようにしています。

```javascript
class HoloduleNavigator extends Component {
    constructor(props) {
        super(props);
        this.handleClick = this.handleClick.bind(this);
    }

    handleClick(mode) {
        if (mode === 'prev') {
            const prevDate = Common.getPrevTextDate(this.props.textDate);
            // 親コンポーネントのメソッドを利用して親コンポーネントの状態を更新
            this.props.onDateChange(prevDate);
        } else if (mode === 'next') {
            ...
        }
    }

    render() {
        ...
    }
}

export class Home extends Component {
    static displayName = Home.name;

    constructor(props) {
        super(props);
        // 諸々の状態を初期化しておく
        this.state = {
            initializing: true,
            loading: true,
            textDate: Common.dateToText(new Date()),
            holodules: []
        };
        this.handleDateChange = this.handleDateChange.bind(this);
    }

    // 子コンポーネントから状態を更新するためのメソッド
    handleDateChange(textDate) {
        this.setState({
            loading: true,
            textDate: textDate
        });
    }

    getAccessToken() {
        const requestOptions = {
            method: 'GET',
            headers: { 'Content-Type': 'application/json' },
        };
        fetch('accesstoken', requestOptions).then(response => {
            if (!response.ok) {
                throw new Error('Network response was not ok');
            }
            return response.json();
        }).then(json => {
            document.cookie = `holodule_token=${json.access_token}`;
            // 非同期処理が完了したときに状態を更新
            this.setState({ initializing: false });
        }).catch(error => {
            console.error('There has been a problem with your fetch operation:', error);
        });
    }

    getHolodules(textDate) {
        ...
    }

    render() {
        const initializing = this.state.initializing;
        const loading = this.state.loading;
        let contents;
        // 状態に応じてコンポーネントの配置を制御
        if (initializing) {
            this.getAccessToken();
            // 初期化中の描画
            contents = <Container>
                <Row className="justify-content-md-center">
                    <Col md="auto">
                        <Spinner color="primary">Initializing...</Spinner>
                    </Col>
                </Row>
            </Container>;
        } else if (loading) {
            // 読み込み中の描画
            ...
        } else {
            // 結果表示の描画
            contents = <Container>
                <Row className="justify-content-md-center">
                    <Col md="auto">
                        <HoloduleNavigator textDate={this.state.textDate} onDateChange={this.handleDateChange} />
                    </Col>
                </Row>
                <Row>
                    <Col>
                        <HoloduleTable holodules={this.state.holodules} />
                    </Col>
                </Row>
            </Container>;
        }
        return ({contents});
    }
}
```

## 開発手順

モックを作成してから課題を乗り越えて段階的に開発を進めましたが、すべてをスキップした完成版を記載しておきます。

とはいえ、エラー処理も含めて作りこみが甘いので、いろいろと実装の問題はあると思います。

また、現時点では本番環境での運用は考えておらず、ローカルでのデバッグ実行までとしています。

## 開発環境

* React
  * node v14.18.2 / npm 6.14.15 (nvm)
  * React 16.12.0
  * ECMAScript 5
* ASP.NET Core
  * .NET 5 (Visual Studio 2022 v17.0.3)
  * ASP.NET Core 5.0
  * C# 9

## Visual Studio 2022 でプロジェクトを作成

1. 新しいプロジェクトとして「React.js での ASP.NEt Core」を選択
    ![img02.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/f95734bc-d386-a730-65f5-14f1df5c26e5.jpeg)

1. 新しいプロジェクトを構成
    ![img03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/2fb0e7cc-9bcd-671a-69e1-50bf8098a398.jpeg)

1. 今回は .NET 5.0 を選択して認証なし
    ![img04.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/4b58609e-941e-b979-cecb-4c2363018326.jpeg)

1. プロジェクト作成時点
    ![img05.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/d3515b4a-89df-d7d9-1b62-d9bcbc26abd0.jpeg)

## テンプレートにより作成されたファイルの削除と修正

1. 下記ファイルを削除
  * WeatherForecast.cs
  * Controllers/WeatherForecastController.cs
  * ClientApp/src/components/Counter.js
  * ClientApp/src/components/FetchData.js
  * ClientApp/src/components/NavMenu.css
  * ClientApp/src/components/NavMenu.js

1. ClientApp/src/components/Layout.js ファイルを修正
    NavMenu の参照を削除しています。

    ```react
    import React, { Component } from 'react';
    import { Container } from 'reactstrap';

    export class Layout extends Component {
        static displayName = Layout.name;

        render() {
            return (
                <div>
                    <Container>
                        {this.props.children}
                    </Container>
                </div>
            );
        }
    }
    ```

    メニューを削除しているので Layout コンポーネントの必要性はあまりありませんが残しています。

1. ClientApp\src\App.js ファイルを修正
    不要な参照を削除し、Home 以外のコンポーネントを削除しています。

    ```react
    import React, { Component } from 'react';
    import { Route } from 'react-router';
    import { Layout } from './components/Layout';
    import { Home } from './components/Home';

    import './custom.css'

    export default class App extends Component {
        static displayName = App.name;

        render() {
            return (
                <Layout>
                    <Route exact path='/' component={Home} />
                </Layout>
            );
        }
    }
    ```

## React 開発

1. 既存の ClientApp\src\components\Home.js ファイルを下記のように書き換え
    ファイルを分けずに Home.js だけで作成しています。

    ```react
    import React, { Component } from 'react';
    import { Button, Spinner, Container, Row, Col, Table, InputGroup, InputGroupText  } from 'reactstrap';

    class HoloduleRow extends Component {
        render() {
            const holodule = this.props.holodule;
            const today = new Date();
            const holoduleDate = Common.textToDateTime(holodule.datetime);
            const timeStyle = holoduleDate.getTime() < today.getTime() ? { color: '#c0c0c0' } : { color: '#87ceeb' };
            const time = `${holoduleDate.getHours()}:${('0' + holoduleDate.getMinutes()).slice(-2)}～`;
            const imgurl = `${process.env.PUBLIC_URL}/${Common.members[holodule.name].img}`;
            const churl = `https://www.youtube.com/channel/${Common.members[holodule.name].ch}`;
            const tmburl = `http://img.youtube.com/vi/${holodule.video_id}/default.jpg`;
            return (
                <tr>
                    <td>
                        <div className="card-container">
                            <div className="card-img">
                                <a href={churl}><img src={imgurl} alt="" /><p>{holodule.name}</p></a>
                            </div>
                            <div className="card-img">
                                <a href={holodule.url}><img src={tmburl} alt="" /></a>
                            </div>
                            <div className="card-text">
                                <h2 style={timeStyle}>{time}</h2>
                                <p>{holodule.title}</p>
                            </div>
                        </div>
                    </td>
                </tr>
            );
        }
    }

    class HoloduleTable extends Component {
        render() {
            const rows = [];
            this.props.holodules.forEach((holodule) => {
                rows.push( <HoloduleRow holodule={holodule} key={holodule.key} /> );
            });
            return (
                <Table hover>
                    <tbody>{rows}</tbody>
                </Table>
            );
        }
    }

    class HoloduleNavigator extends Component {
        constructor(props) {
            super(props);
            this.handleClick = this.handleClick.bind(this);
        }

        handleClick(mode) {
            if (mode === 'prev') {
                const prevDate = Common.getPrevTextDate(this.props.textDate);
                this.props.onDateChange(prevDate);
            } else if (mode === 'next') {
                const nextDate = Common.getNextTextDate(this.props.textDate);
                this.props.onDateChange(nextDate);
            }
        }

        render() {
            return (
                <InputGroup size="lg">
                    <Button color="primary" onClick={() => this.handleClick('prev')}>昨日</Button>
                    <InputGroupText>{this.props.textDate}</InputGroupText>
                    <Button color="primary" onClick={() => this.handleClick('next')}>明日</Button>
                </InputGroup>
            );
        }
    }

    export class Home extends Component {
        static displayName = Home.name;

        constructor(props) {
            super(props);
            this.state = {
                initializing: true,
                loading: true,
                textDate: Common.dateToText(new Date()),
                holodules: []
            };
            this.handleDateChange = this.handleDateChange.bind(this);
        }

        handleDateChange(textDate) {
            this.setState({
                loading: true,
                textDate: textDate
            });
        }

        getAccessToken() {
            const requestOptions = {
                method: 'GET',
                headers: { 'Content-Type': 'application/json' },
            };
            fetch('accesstoken', requestOptions).then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            }).then(json => {
                document.cookie = `holodule_token=${json.access_token}`;
                this.setState({ initializing: false });
            }).catch(error => {
                console.error('There has been a problem with your fetch operation:', error);
            });
        }

        getHolodules(textDate) {
            const access_token = Common.getValueFromCookie("holodule_token");
            if (!access_token) {
                this.setState({ initializing: true });
                return;
            }
            const date = Common.removeSeparatorTextDate(textDate);
            const requestOptions = {
                method: 'GET',
                headers: { 'Content-Type': 'application/json', 'Authorization': `JWT ${access_token}` },
            };
            const queryParams = new URLSearchParams(`date=${date}`);
            fetch(`holodule?${queryParams}`, requestOptions).then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            }).then(json => {
                this.setState({ loading: false, holodules: json.holodules });
            }).catch(error => {
                console.error('There has been a problem with your fetch operation:', error);
                this.setState({ loading: false, holodules: [] });
            });
        }

        render() {
            const initializing = this.state.initializing;
            const loading = this.state.loading;
            let contents;
            if (initializing) {
                this.getAccessToken();
                contents = <Container>
                    <Row className="justify-content-md-center">
                        <Col md="auto">
                            <Spinner color="primary">Initializing...</Spinner>
                        </Col>
                    </Row>
                </Container>;
            } else if (loading) {
                this.getHolodules(this.state.textDate);
                contents = <Container>
                    <Row className="justify-content-md-center">
                        <Col md="auto">
                            <Spinner color="success">Loading...</Spinner>
                        </Col>
                    </Row>
                </Container>;
            } else {
                contents = <Container>
                    <Row className="justify-content-md-center">
                        <Col md="auto">
                            <HoloduleNavigator textDate={this.state.textDate} onDateChange={this.handleDateChange} />
                        </Col>
                    </Row>
                    <Row>
                        <Col>
                            <HoloduleTable holodules={this.state.holodules} />
                        </Col>
                    </Row>
                </Container>;
            }
            return (<div>{contents}</div>);
        }
    }

    export class Common {
        static dateToText(date) {
            const year = date.getFullYear().toString().padStart(4, '0');
            const month = (1 + date.getMonth()).toString().padStart(2, '0');
            const day = date.getDate().toString().padStart(2, '0');
            let format = 'YYYY/MM/DD';
            format = format.replace(/YYYY/g, year);
            format = format.replace(/MM/g, month);
            format = format.replace(/DD/g, day);
            return format;
        }

        static textToDate(textDate) {
            const year = textDate.substr(0, 4);
            const month = Number(textDate.substr(5, 2)) - 1;
            const day = textDate.substr(8, 2);
            return new Date(year, month, day);
        }

        static getNextTextDate(textDate) {
            const date = Common.textToDate(textDate);
            return Common.dateToText(new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1));
        }

        static getPrevTextDate(textDate) {
            const date = Common.textToDate(textDate);
            return Common.dateToText(new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1));
        }

        static removeSeparatorTextDate(textDate) {
            return textDate.replace(/\//g, '');
        }

        static textToDateTime(textDateTime) {
            const year = textDateTime.substr(0, 4);
            const month = Number(textDateTime.substr(4, 2)) - 1;
            const day = textDateTime.substr(6, 2);
            const hour = textDateTime.substr(9, 2);
            const minute = textDateTime.substr(11, 2);
            const second = textDateTime.substr(13, 2);
            return new Date(year, month, day, hour, minute, second);
        }

        static getValueFromCookie(cookieName) {
            const cookies = document.cookie;
            const cookiesArray = cookies.split(';');
            for (var c of cookiesArray) {
                var cArray = c.split('=');
                if (cArray[0].trim() === cookieName) {
                    return cArray[1];
                }
            }
            return undefined;
        }

        static members = {
            "ときのそら": { img: "tokino_sora.jpg", ch: "UCp6993wxpyDPHUpavwDFqgg" },
            "ロボ子さん": { img: "robokosan.jpg", ch: "UCDqI2jOz0weumE8s7paEk6g" },
            "さくらみこ": { img: "sakura_miko.jpg", ch: "UC-hM6YJuNYVAmUWxeIr9FeA" },
            "星街すいせい": { img: "hoshimachi_suisei.jpg", ch: "UC5CwaMl1eIgY8h02uZw7u8A" },
            "夜空メル": { img: "yozora_mel.jpg", ch: "UCD8HOxPs4Xvsm8H0ZxXGiBw" },
            "アキ・ローゼンタール": { img: "aki_rosenthal.jpg", ch: "UCFTLzh12_nrtzqBPsTCqenA" },
            "赤井はあと": { img: "haachama.jpg", ch: "UC1CfXB_kRs3C-zaeTG3oGyg" },
            "白上フブキ": { img: "shirakami_fubuki.jpg", ch: "UCdn5BQ06XqgXoAxIhbqw5Rg" },
            "夏色まつり": { img: "natsuiro_matsuri.jpg", ch: "UCQ0UDLQCjY0rmuxCDE38FGg" },
            "湊あくあ": { img: "minato_aqua.jpg", ch: "UC1opHUrw8rvnsadT-iGp7Cg" },
            "紫咲シオン": { img: "murasaki_shion.jpg", ch: "UCXTpFs_3PqI41qX2d9tL2Rw" },
            "百鬼あやめ": { img: "nakiri_ayame.jpg", ch: "UC7fk0CB07ly8oSl0aqKkqFg" },
            "癒月ちょこ": { img: "yuzuki_choco.jpg", ch: "UC1suqwovbL1kzsoaZgFZLKg" },
            "大空スバル": { img: "oozora_subaru.jpg", ch: "UCvzGlP9oQwU--Y0r9id_jnA" },
            "大神ミオ": { img: "ookami_mio.jpg", ch: "UCp-5t9SrOQwXMU7iIjQfARg" },
            "猫又おかゆ": { img: "nekomata_okayu.jpg", ch: "UCvaTdHTWBGv3MKj3KVqJVCw" },
            "戌神ころね": { img: "inugami_korone.jpg", ch: "UChAnqc_AY5_I3Px5dig3X1Q" },
            "兎田ぺこら": { img: "usada_pekora.jpg", ch: "UC1DCedRgGHBdm81E1llLhOQ" },
            "潤羽るしあ": { img: "uruha_rushia.jpg", ch: "UCl_gCybOJRIgOXw6Qb4qJzQ" },
            "不知火フレア": { img: "shiranui_flare.jpg", ch: "UCvInZx9h3jC2JzsIzoOebWg" },
            "白銀ノエル": { img: "shirogane_noel.jpg", ch: "UCdyqAaZDKHXg4Ahi7VENThQ" },
            "宝鐘マリン": { img: "housyou_marine.jpg", ch: "UCCzUftO8KOVkV4wQG1vkUvg" },
            "天音かなた": { img: "amane_kanata.jpg", ch: "UCZlDXzGoo7d44bwdNObFacg" },
            "桐生ココ": { img: "kiryu_coco.jpg", ch: "UCS9uQI-jC3DE0L4IpXyvr6w" },
            "角巻わため": { img: "tsunomaki_watame.jpg", ch: "UCqm3BQLlJfvkTsX_hvm0UmA" },
            "常闇トワ": { img: "tokoyami_towa.jpg", ch: "UC1uv2Oq6kNxgATlCiez59hw" },
            "姫森ルーナ": { img: "himemori_luna.jpg", ch: "UCa9Y57gfeY0Zro_noHRVrnw" },
            "獅白ぼたん": { img: "shishiro_botan.jpg", ch: "UCUKD-uaobj9jiqB-VXt71mA" },
            "雪花ラミィ": { img: "yukihana_lamy.jpg", ch: "UCFKOVgVbGmX65RxO3EtH3iw" },
            "尾丸ポルカ": { img: "omaru_polka.jpg", ch: "UCK9V2B22uJYu3N7eR_BT9QA" },
            "桃鈴ねね": { img: "momosuzu_nene.jpg", ch: "UCAWSyEs_Io8MtpY3m-zqILA" },
            "魔乃アロエ": { img: "mano_aloe.jpg", ch: "UCYq8Zfxf9iYTci5EGEGnkLw" },
            "ラプラス": { img: "laplus_darknesss.jpg", ch: "UCENwRMx5Yh42zWpzURebzTw" },
            "鷹嶺ルイ": { img: "takane_lui.jpg", ch: "UCs9_O1tRPMQTHQ-N_L6FU2g" },
            "博衣こより": { img: "hakui_koyori.jpg", ch: "UC6eWCld0KwmyHFbAqK3V-Rw" },
            "風真いろは": { img: "kazama_iroha.jpg", ch: "UC_vMYWcDjmfdpH6r4TTn1MQ" },
            "沙花叉クロヱ": { img: "sakamata_chloe.jpg", ch: "UCIBY1ollUsauvVi4hW4cumw" }
        }
    }
    ```

1. ClientApp\public\ に上記定義の顔写真ファイルを配置
    ![img06.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/96162b74-a754-c9fb-3a5b-481b5b3fd589.jpeg)

1. 既存の ClientApp\src\custom.css ファイルを下記のように書き換え
    レイアウトはできるだけ reactstrap で片付けようと思いましたが、カードレイアウトだけ作りこみました。

    ```css
    .card-container {
        background: #333;
        color: #fff;
        display: flex;
        margin: auto;
        width: 100%;
        box-shadow: 0 12px 10px -6px rgba(0,0,0,.25);
    }

        .card-container a {
            text-decoration: none;
        }

    .card-img {
        width: 120px;
        margin: auto 0px auto 10px;
        position: relative;
    }

        .card-img p {
            position: absolute;
            top: 0px;
            left: 3px;
            color: #000;
            text-shadow: 1px 1px 0 #FFF, -1px -1px 0 #FFF, -1px 1px 0 #FFF, 1px -1px 0 #FFF, 0px 1px 0 #FFF, 0-1px 0 #FFF, -1px 0 0 #FFF, 1px 0 0 #FFF;
        }

        .card-img img {
            width: 100%;
        }

    .card-text {
        width: 100%;
        margin: 10px 10px;
    }

        .card-text h2 {
            font-size: 1em;
        }

        .card-text h3 {
            font-size: 0.9em;
        }

        .card-text h4 {
            font-size: 0.8em;
        }

        .card-text p {
            font-size: 0.6em;
        }

    @media screen and (min-width:650px) {
        date-text {
            font-size: 1em;
        }

        .card-container {
            max-width: 700px;
            height: 140px;
        }

        .card-text h2 {
            font-size: 1.3em;
        }

        .card-text h3 {
            font-size: 1.2em;
        }

        .card-text h4 {
            font-size: 1.1em;
        }

        .card-text p {
            font-size: 1em;
        }
    }
    ```

## ASP.NET Core Web API 開発

1. Services\IDataService.cs ファイルの作成
    シークレットから取得した Web API の接続情報をコントローラーに渡すためのインターフェースです。

    ```csharp
    namespace holoduleweb.Services
    {
        public interface IDataService
        {
            string UserName { get; }
            string Password { get; }
            string BaseUrl { get; }
        }
    }
    ```

1. Services\DataService.cs
    シークレットから取得した Web API の接続情報をコントローラーに渡すためのインターフェースの実装です。

    ```csharp
    namespace holoduleweb.Services
    {
        public class DataService : IDataService
        {
            private readonly string _userName;
            private readonly string _password;
            private readonly string _baseUrl;

            public DataService(string userName, string password, string baseUrl)
            {
                _userName = userName;
                _password = password;
                _baseUrl = baseUrl;
            }

            public string UserName => _userName;
            public string Password => _password;
            public string BaseUrl => _baseUrl;
        }
    }
    ```

1. Startup.cs ファイルの修正
    Web API コントローラーへ接続情報などを渡すための依存関係を挿入しています。

    ```csharp
    using holoduleweb.Services;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;

    namespace holoduleweb
    {
        public class Startup
        {
            public IConfiguration Configuration { get; }
            public Startup(IConfiguration configuration)
            {
                Configuration = configuration;
            }

            public void ConfigureServices(IServiceCollection services)
            {
                var userName = Configuration["HoloduleWeb:UserName"];
                var password = Configuration["HoloduleWeb:Password"];
                var baseUrl = Configuration["HoloduleWeb:BaseUrl"];
                services.AddTransient<IDataService>(_ => new DataService(userName, password, baseUrl));
                services.AddControllersWithViews();
                // 本番環境の場合、Reactのファイルはこのディレクトリに保管しておく
                services.AddSpaStaticFiles(configuration =>
                {
                    configuration.RootPath = "ClientApp/build";
                });
            }

            public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
            {
                if (env.IsDevelopment())
                {
                    app.UseDeveloperExceptionPage();
                }
                else
                {
                    app.UseExceptionHandler("/Error");
                    app.UseHsts();
                }
                app.UseHttpsRedirection();
                app.UseStaticFiles();
                app.UseSpaStaticFiles();
                app.UseRouting();
                app.UseEndpoints(endpoints =>
                {
                    endpoints.MapControllerRoute(
                        name: "default",
                        pattern: "{controller}/{action=Index}/{id?}");
                });
                app.UseSpa(spa =>
                {
                    spa.Options.SourcePath = "ClientApp";
                    if (env.IsDevelopment())
                    {
                        spa.UseReactDevelopmentServer(npmScript: "start");
                    }
                });
            }
        }
    }
    ```

1. Visual Studio のユーザーシークレットマネージャーを利用して secrets.json を修正
    開発環境用の設定情報を設定しておきます。

    ```json
    {
        "HoloduleWeb": {
            "UserName": "Flask Web API のユーザー名",
            "Password": "Flask Web API のパスワード",
            "BaseUrl": "Flask Web API のURL"
        }
    }
    ```

1. アクセストークンを取得する Flask Web API を呼び出す Web API の作成
    設定情報から取得した接続情報を用いて Flask Web API を呼び出し、取得した JWT のアクセストークンを返却しています。

    ```csharp
    using holoduleweb.Services;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Logging;
    using Newtonsoft.Json;
    using RestSharp;
    using System;

    namespace holoduleweb.Controllers
    {
        [ApiController]
        [Route("[controller]")]
        public class AccessTokenController : ControllerBase
        {
            private readonly IDataService _dataService;
            private readonly ILogger<AccessTokenController> _logger;

            public AccessTokenController(ILogger<AccessTokenController> logger, IDataService dataService)
            {
                _logger = logger;
                _dataService = dataService;
            }

            [HttpGet]
            public string Get()
            {
                var auth = new Auth(_dataService.UserName, _dataService.Password);
                var json = JsonConvert.SerializeObject(auth);

                var client = new RestClient();
                client.BaseUrl = new Uri(_dataService.BaseUrl);

                var request = new RestRequest("auth", Method.POST);
                request.Parameters.Clear();
                request.AddHeader("Content-Type", "application/json");
                request.AddParameter("application/json", json, ParameterType.RequestBody);

                var response = client.Execute(request);
                if (response.IsSuccessful)
                {
                    return response.Content;
                }
                return "error";
            }

            [JsonObject]
            public class Auth
            {
                [JsonProperty("username")]
                public string Username { get; private set; }

                [JsonProperty("password")]
                public string Password { get; private set; }

                public Auth(string username, string password)
                {
                    this.Username = username;
                    this.Password = password;
                }
            }
        }
    }
    ```

1. MongoDB に格納しているホロライブの配信情報を取得する Flask Web API を呼び出す Web API の作成
    設定情報から取得した接続情報と、事前に取得した JWT のアクセストークンを用いて Flask Web API を呼び出し、取得したホロライブの配信情報を返却しています。パラメーターは配信日です。

    ```csharp
    using holoduleweb.Services;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.Extensions.Logging;
    using RestSharp;
    using System;

    namespace holoduleweb.Controllers
    {
        [ApiController]
        [Route("[controller]")]
        public class HoloduleController : ControllerBase
        {
            private readonly IDataService _dataService;
            private readonly ILogger<HoloduleController> _logger;

            public HoloduleController(ILogger<HoloduleController> logger, IDataService dataService)
            {
                _logger = logger;
                _dataService = dataService;
            }

            [HttpGet]
            public string Get(string date)
            {
                string authorization = Request.Headers["Authorization"];

                var client = new RestClient();
                client.BaseUrl = new Uri(_dataService.BaseUrl);

                var request = new RestRequest($"holodules/{date}", Method.GET);
                request.Parameters.Clear();
                request.AddHeader("Content-Type", "application/json");
                request.AddHeader("Authorization", authorization);

                var response = client.Execute(request);
                if (response.IsSuccessful)
                {
                    return response.Content;
                }
                return "error";
            }
        }
    }
    ```

## デバッグ実行

初回デバッグ実行時は、Node Package のインストールや環境の初期化により起動するまでにかなり時間がかかります。
![img07.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283306/8678912b-f62e-ded4-52d3-c2767666bbab.jpeg)
ホロジュール Web アプリができました。
日付ごとに当日や前日などのホロライブの配信スケジュールを確認できます。

次回は、Azure App Service にデプロイしてみようかなと思います。