個人開発でWebサービス(パズルアプリ)をリリースしたときの話

## 作ったサービス

### 概要
オートマトンのパズルアプリです。
お題のオートマトンをユーザが作成していくサービスです。

ここでは、動機や使用技術、ユーザの反応を中心に記載していきます。
※詳しいサービスの内容は、WebサービスのURLから見ることができます。

#### プレイイメージ
![play.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321382/7af5483a-7799-ffc8-7660-6a7239830e8f.gif)

#### WebサービスのURL
https://threeislands.github.io/automaton-app/#/

#### ソースコード
https://github.com/threeislands/automaton-app

## 動機
以前、オートマトンに関する本を読んだときに、正規言語とその状態遷移図が記載されていました。

そのときに「正規言語から状態遷移図を作成する過程は、非常に面白いのではないか！？」と思いました。

#### 他の理由
- オートマトンの描画を実装できるか検証したい
- オートマトンの面白さを他の人にも伝えたい！！

そこで、実際につくってみることにしました。

## 機能概要
- オートマトン描画機能  
- オートマトン判定機能  
- ログイン機能
- オートマトンの保存／取得機能（ログイン済みのユーザのみ）

## 技術スタック
### 概要
今回は、フロントエンドとバックエンドを分離した SPA + BFF の構成にしました。

### 理由
- これまでMVCフレームワークを使ったWeb開発が多かったのですが、その際にテンプレートエンジン(Thymeleaf)とJavaScript(jQuery)のそれぞれの変数が混在していて、可読性があまり高くないと感じたため
- SPAを採用した場合の認証やログインの実装方法に興味があったため

### フロントエンド
#### React, (D3)
SPAフレームワークには、Reactを採用しました。
また、一部にD3を利用しました。(ドラッグ&ドロップの処理)

### バックエンド
#### Flask, SQLAlchemy
バックエンドのAPIサーバのフレームワークに、Flaskを利用しました。
また、ORMには、SQLAlchemyを利用しました。
marshmallowというライブラリを利用することで、簡単にモデル⇔JSONを相互変換できるのが便利でした。

### CI
CIには、GitHub Actionsを利用しました。
また、バックエンド(Flask)はpytestを使用してテストコードを作成しました。

### インフラ
#### GitHub Pages
フロントエンド(React)はGitHub Pages上で、運用しています。
GitHub PagesはPublicリポジトリであれば、無料で利用できる点が魅力的でした。
また、gh-pagesというライブラリ利用することで、簡単にデプロイできるのも良いと思いました。

#### AWS (CloudFront, Elastic Beanstalk(EC2))
バックエンド(Flask)はElastic Beanstalk(EC2)上で、運用しています。
CloudFrontにSSL証明書を設定して、HTTPSにしています。

クライアント(ブラウザ) - GitHub Pages 間にCloudFrontを配置することで、フロントエンドとバックエンドのoriginを同じにしています。
それによって、CookieにSameSite属性(=Lax)を指定して、CSRFの対策をおこなえるようにしています。(Cookieベースの認証を利用している)

## インフラ構成
![cluod_infra.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321382/98b701a2-38b6-84af-a242-e1a70df60d81.jpeg)

リクエストパスが `/api` から始まる場合、CloudFrontでEC2にリバースプロキシするようにしています。
それ以外のリクエストパスの場合、Github Pagesにリバースプロキシするようにしています。

## 認証
### 概要
今回は、ログイン機能にGoogleのOpenID ConnectとTwitterのOAuth1.0を利用しました。

#### Google / OpenID Connect の場合
1. 外部サービスのログイン後に、APIサーバにリダイレクトする
2. リダイレクト時のパスに含まれるコード値から、id_tokenを取得する外部APIを実行する
3. 3. 取得したid_tokenのsubがユーザテーブルに登録されていれば(未登録の場合、新規登録)、SessionにユーザIDを設定して、認証済みのユーザとする

#### Twitter / OAuth1.0 の場合
1. 外部サービスのログイン後に、APIサーバにリダイレクトする
2. リダイレクト時のパスに含まれるoauth_token, oauth_verifierから、アクセストークンを取得する外部APIを実行する
3. 取得したトークンのuser_idがユーザテーブルに登録されていれば(未登録の場合、新規登録)、SessionにユーザIDを設定して、認証済みのユーザとする

### フロントエンド

#### React
初回ロード時に、ユーザ情報取得のAPIを実行します。
認証済みの場合のみ、ユーザ情報を取得できます。
取得したユーザ情報を、`UserContext` に設定して他のコンポーネントから参照できるようにしています。

```App.js
function App() {

  const [user, setUser] = useState(null);

  useEffect(() => {
    const api = async () => {
      const user = await UserService.getUser();
      // 未ログインの場合、空のobjectが返却されるのでnullを設定
      if (Object.keys(user).length === 0) {
        setUser(null);
      } else {
        setUser(user);
      }
    }
    api();
  }, []);

  return (
    <div className="App">
      <CssBaseline/>
      <Suspense fallback="loading">
        <UserContext.Provider value={user}>
          {/* Other Components... */}
        </UserContext.Provider>
      </Suspense>
    </div>
  );
)
```

UserContextの参照例（該当箇所のみ抜粋）
ログイン状態に応じて、プロフィール欄とログインボタンの表示を切り替える

```Header.js
function Header(props) {

  const user = React.useContext(UserContext);
  const profileArea = user ? UserProfile : LoginButton;

  return (
     <Container>
      <Grid>
        {profileArea()}
        {/* Other Components */}
      </Grid>
    </Container>
}
```

#### API (axios)
APIを実行するライブラリとして、axiosを利用しています。
リクエストの際にCookieも送付するように、`withCredentials`をの設定をします。

```base-service.js
export default axiosBase.create({
  baseURL: Constant.API_ENDPOINT,
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
  responseType: 'json'
});
```

### バックエンド

#### 認証状態をチェックするデコレータ
今回は認証用のライブラリを利用せずに自前で実装しました。
Sessionに *user_id* が設定されている場合、認証済みとしています。

```authentication_required.py
from functools import wraps
from flask import session, jsonify

def authentication_required(f):

    @wraps(f)
    def decorated_function(*args, **kwargs):
        if session.get('user_id') is None:
            return jsonify({'message': 'Unauthorized'}), 401

        return f(*args, **kwargs)

    return decorated_function
```

#### 上記デコレータの利用例
作成したオートマトンを保存できるAPI
認証済みのユーザのみ、このAPIを呼び出すことができる

```user_api.py
@bp.route('/user/save_automaton/<int:question_id>', methods=['POST'])
@authentication_required
def save_automaton(question_id):
    user_id = session.get('user_id')

    req_body = request.json
    schema = AutomatonSchema()
    automaton_data = schema.load(req_body)

    created_automaton = CreatedAutomaton(
        user_id=user_id, question_id=question_id,
        automaton_data=schema.dump(automaton_data))

    AutomatonService.create(created_automaton)

    return make_response(jsonify({'message': 'success'}))
```

## 印象に残っていること

### タイトル／デザインについて
当初は、タイトルを「オートマトンの部屋」にして、Mine◯raftのような配色にすることを想定していました。

開発中に、「恋する寄◯虫」という漫画から着想を得て、「恋するオートマトン」が思いつき、語呂がけっこう良かったので採用しました。

デザインもタイトルに合わせて、ピンクと水色を基調としたPOPな配色にしました。

### エフェクトについて
当初は、回答時のアニメーションにエフェクトをつけることは予定していませんでした。

しかし、アニメーション実装後に（エフェクトなしで）プレイしてみたところ、爽快感や気持ちよさをあまり感じませんでした。

そこで、正解時に花火のようなエフェクトを設定しました。
多少なりとも、爽快感を感じるようになったと思います。

![animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321382/d2692da2-8674-941c-5744-116caf10961d.gif)

エフェクトのアニメーションはSVGの*animate*, *animateMotion*, *animateTransform*を利用して実装しました。
CSSの Key frame と異なり、動的にアニメーションの時間や変化量、角度を設定できます。

実際のソースは以下で確認できます。
https://github.com/threeislands/automaton-app/blob/master/frontend/src/components/ClearEffect.js

## リリース後の反応
リリースして、友人や知り合いなどだいたい20人ぐらいの人にプレイしてもらうことができました。

### ポジティブなフィードバック
- 面白い
- 楽しかった

エンジニアやゲームが好きな人、数学好きな人からは好意的なフィードバックをもらうことが多く、とても嬉しかったです。
また、レベル７や８(結構むずかしい問題)でもあっさりとクリアしてしまう人がいたことには、とても驚かされました。

### どちらかというとネガティブなフィードバック
- 難しすぎる
- 意味分かんない！
- 説明は理解できたけど、どうやっていいかわからない
- 出会い系サイトっぽい

まず、プレイするにあたって理解しておくルールが多いため、ユーザの認知的な負荷が大きくなってしまいました。
改善案として、インタラクティブなオンボーディングを導入することを検討したいです。(ユーザの操作ごとに説明文が更新される等)

出会い系サイトっぽいという意見はちょっと意外でした。笑
素敵なオートマトンに出会えるので、ある意味出会い系サイトかもしれません！？

## 感想
今回、初めて個人でサービスを開発してリリースまでおこなうことができました。

自分の開発したサービスをユーザが利用してくれることはとても嬉しく、フィードバックからも多くの洞察を得ることができました。

今回作成したサービスは、自分の作りたいサービスでしたが、次回はユーザの潜在的なニーズに合致するサービスを作りたいと思います。