フロントエンド/バックエンドを分離して開発するためにHoverflyを利用してみる

## はじめに

以下の記事で『HTTP(S)トラフィック検証用のAPIサーバを即座に提供するOSS』と題してHoverflyを紹介しました．

https://qiita.com/caunu-s/items/4b14029995ef55d65438

本記事では，Hoverflyの活用例をご紹介したいと思います．
今回例として作成としたアプリは，HTTPを利用して通信を行います．
APIサーバはNode.jsとMySQLによって作成されており，Pyhonを利用するClientからのrequestに応じてresponseを返します．

![スライド0.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/cb919443-941e-c041-ad4b-69873545298d.png)

このAPIサーバについて，Hoverflyを用いてテスト用APIサーバに差し替えます．

![スライド1.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/7bdfd19b-e9cd-c4ec-c153-ba9bba45d0e3.png)

次の開発の流れに沿って順に説明をしていきます．

1. バックエンドを用意する．（通信内容の決定，作成，疎通確認）
1. **バックエンドをモック化して，フロントエンドとバックエンドを完全に切り離す．**
1. フロントエンド，バックエンドそれぞれの開発を進める．

## 動作確認環境

| 名称 | バージョン |
|----|----|
| Windows 10 | 21H2 | 
| Hoverfly | 1.3.0 | 
| hoverctl | master-3578 | 
| Python | 3.10.0 | 
| Node.js | 14.18.1 |
| express | 4.17.1 |
| npm | 6.14.15 |
| MySQL Comminity | 8.0.27 |

Hoverflyは以下のOSに対応しています．

* MacOS 64bit
* Linux 32bit/64bit
* Windows 32bit/64bit

本記事では，Hoverfly公式[^Hoverfly_official]でも記述が少ないWindowsを利用した例を示します．
Node.js, express, MySQLによるAPIサーバの立て方については様々な記事[^api_ex]があるので詳細は割愛させていただきます．
requestの送信にはPythonを利用しました．

## インストール

### Hoverfly

公式ページ[^Hov_dl]からダウンロードします．
zipファイルを解凍し，『システムの詳細設定』=>『環境変数』から解凍したフォルダにPathを通せば完了です．
`hoverctl`は，Hoverfly APIとローカルのファイルシステム間の連携設定やHoverflyの実行を担うコマンドラインツールです．
`hoverfly`はアプリケーション本体で，プロキシサーバやウェブサーバ，APIエンドポイントを提供します．
インストールされたことは以下のコマンドで確認できます．

```shell
hoverfly -version
> v1.3.0

hoverctl version
> +----------+-------------+
> | hoverctl | master-3578 |
> | hoverfly | v1.3.0      |
> +----------+-------------+
```

### Python

こちらも公式ページ[^Py_dl]からインストーラを取得してインストールを行います．
インストール後，PythonのPathが通っていることも確認します．
request送信時に利用する`requests`, `json`パッケージもインストールしておきます．

```shell
python --version
> Python 3.10.0

pip install requests
pip install json
```

### Node.js, express

Node.jsも公式ページ[^Node_dl]から取得します．
APIサーバの作成で利用する`express`, `body-parser`, `cors`などを`npm`を使ってインストールしておきます．

```shell
node -v
> v14.18.1

npm init # 環境の依存関係を記述するpackage.jsonを作成します．

npm install express --save
npm install body-parser --save
npm install cors --save
npm install mysql --save # DBとの接続で利用します．

# package.jsonを見るか，次のコマンドを打ってインストールしたパッケージを確認します．
npm ls
```

### MySQL

MySQLのインストーラも公式[^SQL_dl]から取得できます．
本記事ではCommunity Serverを利用しました．

## Hoverflyを利用した開発

テスト用APIサーバを作成すると，**フロントエンドの開発担当者はテスト用APIサーバにrequestを送りながらフロントエンド開発を行い，バックエンド担当者は独立してDB変更やデータ追加などのバックエンド開発を行うことが容易になります．**
このように役割を明確に分けておくことで，**統合時の衝突を避けることができます．**
そこで，Hoverflyの『HTTP(S)を利用する既存のサービスについて簡単にテスト用APIサーバを作成できる』機能を活用したいと考えました．

本記事では，ランダムに昆虫の情報を返してくれる"昆虫図鑑アプリ"を題材に使ってみたいと思います．
また，Hoverflyにはランダムな返答の順番も再現したモックを作成してほしい場合を考えます．

まずHoverflyをCapture modeで起動し，requestと返されたresponseを元に`Simulations`を構成します．
次にHoverflyをSimulate modeで起動し，Clientがテスト用APIサーバにrequestを送ることで`Simulations`に基づいたresponseを取得します．
途中，必要に応じて`Simulations`を`simulation.json`にexportし，内容を編集してimportします．

### 通信内容

本記事で取り扱うアプリは次のようなデータ(JSON)のやり取りを行います．

![通信.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/5a737dfa-12df-93f5-61cc-8608d0506e26.png)

Clientからは，ユーザ情報（`{"user":"太郎"}`など）をリクエストパラメータに添えてrequestを送ります．
DB(MySQL)には生物の分類（界門網目科）情報が格納されています．
Node.jsはユーザ情報（ユーザ名）から日本語か英語かどちらで返答するべきかを判定し，DBに格納されている表から分類情報をランダムに取得します．
さらに，取得した分類情報（`{'id': 1, 'name': 'ハナアブ', 'kingdom': '動物界', 'phylum': '節足動物門', 'class': '昆虫綱', 'order': 'ハエ目', 'family': 'ハナアブ科'}`など）をレスポンスボディに含めて返します．

Hoverflyを導入した後は次のようなやり取りになります．

![通信Hov.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997806/12a75158-09bb-b452-b801-eb27672b2160.png)

Hoverflyを用いてモック化することで，バックエンドを意識することなくフロントの開発をできるようになっていることがわかると思います．

### APIサーバの構築

MySQLでhoverdbデータベースを作成し，日本語版と英語版の表を格納します．

```
mysql> select * from testJPNTable;
+--------+----------------+-------------+------------+-----------+-----------+--------------+
| ID_JPN | NAME_JPN       | KINGDOM_JPN | PHYLUM_JPN | CLASS_JPN | ORDER_JPN | FAMILY_JPN   |
+--------+----------------+-------------+------------+-----------+-----------+--------------+
|      1 | ハナアブ　　　　 | 動物界　　　 | 節足動物門 | 昆虫綱     | ハエ目     | ハナアブ科　　|
|      2 | カブトムシ　　　 | 動物界　　　 | 節足動物門 | 昆虫綱     | 甲虫目     | コガネムシ科　|
|      3 | ミツバチ　　　　 | 動物界　　　 | 節足動物門 | 昆虫綱     | ハチ目     | ミツバチ科　　|
|      4 | アキアカネ　　　 | 動物界　　　 | 節足動物門 | 昆虫綱     | トンボ目   | トンボ科　　  |
|      5 | トノサマバッタ　 | 動物界　　　 | 節足動物門 | 昆虫綱     | バッタ目   | バッタ科　　  |
+--------+----------------+-------------+------------+-----------+-----------+--------------+

mysql> select * from testENGTable;
+--------+----------------------------+-------------+------------+-----------+-------------+--------------+
| ID_ENG | NAME_ENG                   | KINGDOM_ENG | PHYLUM_ENG | CLASS_ENG | ORDER_ENG   | FAMILY_ENG   |
+--------+----------------------------+-------------+------------+-----------+-------------+--------------+
|      1 | Hover fly                  | Animalia    | Arthropoda | Insecta   | Diptera     | Syrphidae    |
|      2 | Japanese rhinoceros beetle | Animalia    | Arthropoda | Insecta   | Coleoptera  | Scarabaeidae |
|      3 | Honey bee                  | Animalia    | Arthropoda | Insecta   | Hymenoptera | Apidae       |
|      4 | Sympetrum frequens         | Animalia    | Arthropoda | Insecta   | Odonata     | Libellulidae |
|      5 | Migratory locust           | Animalia    | Arthropoda | Insecta   | Orthoptera  | Acrididae    |
+--------+----------------------------+-------------+------------+-----------+-------------+--------------+
```

requestに応じて接続したDBからランダムにデータを取得して返答するように，以下のJavaScriptを書きます．

```JavaScript:apiserver.js
const exp = require("express");
const bodyParser = require('body-parser');
const cors = require('cors');
const app = exp();
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
app.use(cors());

const sql = require("mysql");
const con = sql.createConnection({
  user : "root",
  host : "localhost",
  password : "password",
  database : "hoverdb"
})

app.listen(3100,function(){
  console.log("Start DB-API Server.");
})

app.post("/test",function(req,res,nex){
  let data = {};
  let user = req.body.user;
  let dataNumAll;
  let dataNum;
  let lang = "ENG"

  new Promise(resolve => {
    for(var i=0; i < user.length; i++){
      if(user.charCodeAt(i) >= 256) {
        lang = "JPN";
        break;
      };
    };
    resolve();
  }).then(() => {
    if (lang == "JPN"){

      return new Promise(resolve => {
        con.query("select count(ID_JPN) as dataNumAll from testJPNTable", async (e, r) => {
          if (e) throw e;
          dataNumAll = r[0].dataNumAll;
          resolve();
        });
      }).then(() => {
        return new Promise(resolve => {
          dataNum = Math.floor(Math.random() * dataNumAll) + 1;
          resolve();
        });
      }).then(() => {
        return new Promise(resolve => {
          con.query("select ID_JPN, NAME_JPN, KINGDOM_JPN, PHYLUM_JPN, CLASS_JPN, ORDER_JPN, FAMILY_JPN from testJPNTable where ID_JPN = ?", [dataNum], async (e,r) => {
            if(e) throw e;
            data["id"] = r[0].ID_JPN;
            data["name"] = r[0].NAME_JPN;
            data["kingdom"] = r[0].KINGDOM_JPN;
            data["phylum"] = r[0].PHYLUM_JPN;
            data["class"] = r[0].CLASS_JPN;
            data["order"] = r[0].ORDER_JPN;
            data["family"] = r[0].FAMILY_JPN;
            resolve();
          });
        });
      }).then(() => {
        res.send(data);
      });

    } else {

      return new Promise(resolve => {
        con.query("select count(ID_ENG) as dataNumAll from testENGTable", async (e, r) => {
          if (e) throw e;
          dataNumAll = r[0].dataNumAll;
          resolve();
        });
      }).then(() => {
        return new Promise(resolve => {
          dataNum = Math.floor(Math.random() * dataNumAll) + 1;
          resolve();
        });
      }).then(() => {
        return new Promise(resolve => {
          con.query("select ID_ENG, NAME_ENG, KINGDOM_ENG, PHYLUM_ENG, CLASS_ENG, ORDER_ENG, FAMILY_ENG from testENGTable where ID_ENG = ?", [dataNum], async (e,r) => {
            if(e) throw e;
            data["id"] = r[0].ID_ENG;
            data["name"] = r[0].NAME_ENG;
            data["kingdom"] = r[0].KINGDOM_ENG;
            data["phylum"] = r[0].PHYLUM_ENG;
            data["class"] = r[0].CLASS_ENG;
            data["order"] = r[0].ORDER_ENG;
            data["family"] = r[0].FAMILY_ENG;
            resolve();
          });
        });
      }).then(() => {
        res.send(data);
      });

    };
  });
});
```

APIサーバが正常にデータを返してくれることを確認します．
コマンドプロンプトを2つ開き，片方でAPIサーバを起動して，もう片方からcurlをします．

```shell
node apiserver.js
> Start DB-API Server.
```
```shell
curl -H "Content-type: application/json" -X POST -d "{\"user\":\"John\"}"  http://localhost:3100/test
> {"id":3,"name":"Honey bee","kingdom":"Animalia","phylum":"Arthropoda","class":"Insecta","order":"Hymenoptera","family":"Apidae"}

curl -H "Content-type: application/json" -X POST -d "{\"user\":\"John\"}"  http://localhost:3100/test
> {"id":2,"name":"Japanese rhinoceros beetle","kingdom":"Animalia","phylum":"Arthropoda","class":"Insecta","order":"Coleoptera","family":"Scarabaeidae"}

curl -H "Content-type: application/json" -X POST -d "{\"user\":\"太郎\"}"  http://localhost:3100/test
> {"id":4,"name":"アキアカネ","kingdom":"動物界","phylum":"節足動物門","class":"昆虫綱","order":"トンボ目","family":"トンボ科"}

curl -H "Content-type: application/json" -X POST -d "{\"user\":\"太郎\"}"  http://localhost:3100/test
> {"id":2,"name":"カブトムシ","kingdom":"動物界","phylum":"節足動物門","class":"昆虫綱","order":"甲虫目","family":"コガネムシ科"}
```

### バックエンドのモック化

コマンドプロンプトから`Simulations`の作成を行ってみます．
まずはHoverflyインスタンスを`--stateful`オプション付きのCapture modeで起動し，数回curlします．
最後に`hoverctl export`コマンドで`simulation.json`を取得します．

```shell
hoverctl start
hoverctl mode capture --stateful

curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"John\"}"  http://localhost:3100/test
> {'id': 1, 'name': 'Hover fly', ...
curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"John\"}"  http://localhost:3100/test
> {'id': 4, 'name': 'Sympetrum frequens', ...
curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"John\"}"  http://localhost:3100/test
> {'id': 2, 'name': 'Japanese rhinoceros beetle', ...
curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"太郎\"}"  http://localhost:3100/test
> {'id': 1, 'name': 'ハナアブ', ...
curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"太郎\"}"  http://localhost:3100/test
> {'id': 3, 'name': 'ミツバチ', ...
curl --proxy http://localhost:8500 -H "Content-type: application/json" -X POST -d "{\"user\":\"太郎\"}"  http://localhost:3100/test
> {'id': 4, 'name': 'アキアカネ', ...

hoverctl export simulation.json
hoverctl stop
```

`simulation.json`の中身を確認してみます．

```JSONC
{
"data": {
        "pairs": [
                 {
                 "request": {
                            "path": [
                                    {
                                    "matcher": "exact",
                                    "value": "/test"
                                    }
                                    ],
                            "method": [
                                      {
                                      "matcher": "exact",
                                      "value": "POST"
                                      }
                                      ],
                            "destination": [
                                           {
                                           "matcher": "exact",
                                           "value": "localhost:3100"
                                           }
                                           ],
                            "scheme": [
                                      {
                                      "matcher": "exact",
                                      "value": "http"
                                      }
                                      ],
                            "body": [
                                    {
                                    "matcher": "json",
                                    "value": "{\"user\":\"John\"}"
                                    }
                                    ],
                            "requiresState": {
                                             "sequence:1": "1"
                                             }
                            },
                 "response": {
                             "status": 200,
                             "body": "{\"id\":1,\"name\":\"Hover fly\",\"kingdom\":...",
                             "encodedBody": false,
                             "headers": {
                                        "Access-Control-Allow-Origin": [
                                                                       "*"
                                                                       ],
                                        "Connection": [
                                                      "keep-alive"
                                                      ],
                                        "Content-Length": [
                                                          "128"
                                                          ],
                                        "Content-Type": [
                                                        "application/json; charset=utf-8"
                                                        ],
                                        "Date": [
                                                "Wed, 15 Dec 2021 07:49:46 GMT"
                                                ],
                                        "Etag": [
                                                "W/\"80-WtD6XUSU8j6nuFnzq6lhrvdWZn8\""
                                                ],
                                        "Hoverfly": [
                                                    "Was-Here"
                                                    ],
                                        "Keep-Alive": [
                                                      "timeout=5"
                                                      ],
                                        "X-Powered-By": [
                                                        "Express"
                                                        ]
                                        },
                             "templated": false,
                             "transitionsState": {
                                                 "sequence:1": "2"
                                                 }
                             }
                 },
                 {
                 "request": {
                            ...
                            }
                 }
                 ],
                 ...
        }
}
```

curlでやり取りを行ったrequest-responseにしたがって`Simulations`が構成されていることがわかります．
`--stateful`オプションをつけていたので，`requiresState`, `transitionsState`によって返答順も管理されています．

ここでは，`http://hogehoge/poyopoyo`にrequestを送った場合は3秒待って`404 Page not found`を返すrequest-responseペアを書き足してみます．

```JSONC
                 "request": {
                            "path": [
                                    {
                                    "matcher": "exact",
                                    "value": "/poyopoyo"
                                    }
                                    ],
                            "method": [
                                      {
                                      "matcher": "exact",
                                      "value": "POST"
                                      }
                                      ],
                            "destination": [
                                           {
                                           "matcher": "exact",
                                           "value": "hogehoge"
                                           }
                                           ],
                            "scheme": [
                                      {
                                      "matcher": "exact",
                                      "value": "http"
                                      }
                                      ],
                            }
                 "response": {
                             "status": 404,
                             "body": "Page not found",
                             "fixedDelay": 3000
                             }
                 }
```

### モックを利用した開発

以上でフロントエンドとバックエンドが切り離されたので，フロントエンド開発担当者はモックを使用したテスト用APIサーバにrequestを送りながら開発を行うことができます．

Pythonでrequestを行うように以下のコードを書きます．
requestの宛先は`http://localhost:3100/test`ですが，プロキシサーバ`http://localhost:8500`として起動したHoverflyによってモックを使って処理をする仕組みです．

```Python:hover_test.py
import requests
import json

proxies = {
"http":"http://localhost:8500/"
}

response = requests.post('http://localhost:3100/test', json = {"user" : "太郎"}, proxies=proxies)
print("1回目(日本語)")
print(response.json())
print("")

response = requests.post('http://localhost:3100/test', json = {"user" : "John"}, proxies=proxies)
print("1回目(英語)")
print(response.json())
print("")

response = requests.post('http://localhost:3100/test', json = {"user" : "太郎"}, proxies=proxies)
print("2回目(日本語)")
print(response.json())
print("")

response = requests.post('http://localhost:3100/test', json = {"user" : "John"}, proxies=proxies)
print("2回目(英語)")
print(response.json())
print("")

response = requests.post('http://localhost:3100/test', json = {"user" : "太郎"}, proxies=proxies)
print("3回目(日本語)")
print(response.json())
print("")

response = requests.post('http://localhost:3100/test', json = {"user" : "John"}, proxies=proxies)
print("3回目(英語)")
print(response.json())
print("")

response = requests.post('http://hogehoge/poyopoyo', json = {"user" : "John"}, proxies=proxies)
print("http://hogehoge/poyopoyo に送った場合")
print(response.text)

```

コマンドプロンプトを2つ開き，片方でテスト用APIサーバを起動して，もう片方から`hover_test.py`を実行します．

```shell
hoverctl start
hoverctl mode simulation
hoverctl import simulation.json
```
```shell
python hover_test.py
> 1回目(日本語)
> {'id': 1, 'name': 'ハナアブ', 'kingdom': '動物界', 'phylum': '節足動物門', 'class': '昆虫綱', 'order': 'ハエ目', 'family': 'ハナアブ科'}
>
> 1回目(英語)
> {'id': 1, 'name': 'Hover fly', 'kingdom': 'Animalia', 'phylum': 'Arthropoda', 'class': 'Insecta', 'order': 'Diptera', 'family': 'Syrphidae'}
>
> 2回目(日本語)
> {'id': 3, 'name': 'ミツバチ', 'kingdom': '動物界', 'phylum': '節足動物門', 'class': '昆虫綱', 'order': 'ハチ目', 'family': 'ミツバチ科'}
>
> 2回目(英語)
> {'id': 4, 'name': 'Sympetrum frequens', 'kingdom': 'Animalia', 'phylum': 'Arthropoda', 'class': 'Insecta', 'order': 'Odonata', 'family': 'Libellulidae'}
>
> 3回目(日本語)
> {'id': 4, 'name': 'アキアカネ', 'kingdom': '動物界', 'phylum': '節足動物門', 'class': '昆虫綱', 'order': 'トンボ目', 'family': 'トンボ科'}
>
> 3回目(英語)
> {'id': 2, 'name': 'Japanese rhinoceros beetle', 'kingdom': 'Animalia', 'phylum': 'Arthropoda', 'class': 'Insecta', 'order': 'Coleoptera', 'family': 'Scarabaeidae'}
>
> http://hogehoge/poyopoyo に送った場合
> Page not found
```

バックエンドのモック化 で実行した順にresponseが返ってきていることがわかります．
あとは，DjangoやFlaskなどを使ってフロント開発を進めればokです．

## おわりに

本記事では，Node.js + MySQLで作成したAPIサーバに対してHoverflyを利用してテスト用APIサーバを作成しました．
また，テスト用APIサーバを利用することでフロントエンドとバックエンドを明確に切り分けて開発する方法を考えてみました．

今回ご紹介した方法は，『まず始めにバックエンドの準備をすること』を前提にしていました．
Hoverflyの優れている点は**既存のバックエンドを迅速にモック化してテスト用APIサーバとして開発環境を提供すること**ですので，**改修という側面が強く出ます．**
よって，既存のバックエンドサービスを利用しながらフロントを改修することを主眼にご紹介しました．
一方で**完全な新規開発を行いたい場合は，Capture modeを利用することなく`Simulations`を全て手書きすることでモックを作成してテスト用APIサーバを利用すればよい**かと思います．
その場合は，JSON Server[^JSON_Serv]などと比較して利用することになると思います．

ぜひ今後の参考にしてみてください！

## （オマケ）会社などのプロキシ環境下でHoverflyを使う

Hoverflyインスタンスを起動する`hoverctl start`コマンドでは，`--upstream-proxy`オプションを使用することで会社などのプロキシ環境下でも外部に通信できます．

```shell
# 会社などのproxy環境下利用したい場合，
hoverctl start --upstream-proxy http://my-user:my-pass@corp.proxy:8080
> Hoverfly is now running
> +------------+------+
> | admin-port | 8888 |
> | proxy-port | 8500 |
> +------------+------+
```

## （オマケ）WindowsでHTTPSトラフィックを利用する

HTTPSトラフィックをキャプチャする場合は，Hoverfly用のSSL証明書[^Hov_ssl]を使います．
公式ページにはwgetを使う方法が書かれています[^Hov_https]が，Windowsではcurlで対応できます．
会社のプロキシ環境下で実行する際は，更に`-x`オプションをつける必要があるかもしれません．

```shell
# Windowsではcurlで対応できる．-x オプションはプロキシ環境下にある場合につける．
curl https://raw.githubusercontent.com/SpectoLabs/hoverfly/master/core/cert.pem -o cert.pem -x http://my-user:my-pass@corp.proxy:8080
```

取得した`cert.pem`を信頼されたルート証明書に登録[^cert]します．
登録が終わったら，`--cacert`オプションをつけてhttpsでcurlしてみます．

```shell
hoverctl start
hoverctl mode simulate

# cert.pemを使ってhttpsでcurl．
curl --proxy localhost:8500 https://example.com --cacert cert.pem
```

更に`curl: (35) schannel: next InitializeSecurityContext failed: Unknown error (0x80092012)`が出る場合は，`--ssl-no-revoke`オプションをつけてSSL 証明書の失効チェックを無効にすると実行できます．

[^Hoverfly_official]: https://docs.hoverfly.io/en/latest/#
[^api_ex]: https://younaship.com/2019/07/09/nodejs-de-api/ がとてもわかりやすかったです．
[^Hov_dl]: https://docs.hoverfly.io/en/latest/pages/introduction/downloadinstallation.html
[^Py_dl]: https://www.python.org/downloads/
[^Node_dl]: https://nodejs.org/ja/download/
[^SQL_dl]: https://dev.mysql.com/downloads/mysql/
[^JSON_Serv]: https://github.com/typicode/json-server
[^Hov_ssl]: https://docs.hoverfly.io/en/latest/pages/tutorials/advanced/configuressl/configuressl.html#configuressl
[^Hov_https]: https://docs.hoverfly.io/en/latest/pages/tutorials/basic/https/https.html
[^cert]: https://jp.globalsign.com/support/faq/10.html を参考に登録できます．