Python＋Reactでレンズ検索データベースを構築した時の技術的な話

# 概要

この度、交換用レンズの情報について、条件を指定して検索できるツールを開発・公開しました。

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">自作のマイクロフォーサーズ用レンズデータベース検索Webアプリを、Firebaseでデプロイしました。<br>追加した検索条件は、同種の条件を追加すると上書きされ、条件自体をクリックすると削除されます。<br>また、詳細ボタンからレンズの詳細データを確認できます。<a href="https://t.co/TRta0pX3H1">https://t.co/TRta0pX3H1</a> <a href="https://t.co/5hQpjaOBiM">pic.twitter.com/5hQpjaOBiM</a></p>&mdash; Y​S​R​@​あ​い​ミ​ス​1​0​章​ク​リ​ア (@YSRKEN) <a href="https://twitter.com/YSRKEN/status/1300021394445537280?ref_src=twsrc%5Etfw">August 30, 2020</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

<blockquote class="twitter-tweet" data-partner="tweetdeck"><p lang="ja" dir="ltr">【お知らせ】レンズを検索できるデータベースを更新しました。検索条件をシェアしたり、クリップボードにコピーしたりできるように！<br><br>レンズデータベース(マイクロフォーサーズ, ライカL マウント向け。スマホ対応！) <a href="https://t.co/TRta0pX3H1">https://t.co/TRta0pX3H1</a> <a href="https://t.co/l5ZA6q9XET">pic.twitter.com/l5ZA6q9XET</a></p>&mdash; Y​S​R​@​あ​い​ミ​ス​1​0​章​ク​リ​ア (@YSRKEN) <a href="https://twitter.com/YSRKEN/status/1306220191764758528?ref_src=twsrc%5Etfw">September 16, 2020</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

今回は、その際に工夫したことについてのまとめです。

# スクレイピング用ライブラリは適宜ラップした

今回のWebアプリでは、レンズについての情報はJSONファイルとして運用していました。
ただ、各レンズの情報を全て手打ちしたわけではありません。
PythonでWebサイトをスクレイピングし、結果をJSONファイルに保存して、フロントエンド側で読み込ませていました。

……その際に使ったライブラリは、[requests-HTML](https://requests.readthedocs.io/projects/requests-html/en/latest/)です。と言っても、そのまま使うのではなく、別途クラスを作成してそちらに処理をまとめています。

```python
from typing import List, MutableMapping, Optional
from requests_html import BaseParser, Element

class DomObject:
    """DOMオブジェクト"""

    def __init__(self, base_parser: BaseParser):
        self.base_parser = base_parser

    def find(self, query: str) -> Optional['DomObject']:
        temp = self.base_parser.find(query, first=True)
        if temp is None:
            return None
        return DomObject(temp)

    def find_all(self, query: str) -> List['DomObject']:
        return [DomObject(x) for x in self.base_parser.find(query)]

    @property
    def text(self) -> str:
        return self.base_parser.text

    @property
    def full_text(self) -> str:
        return self.base_parser.full_text

    # noinspection PyTypeChecker
    @property
    def attrs(self) -> MutableMapping:
        temp: Element = self.base_parser
        return temp.attrs
```

なぜかと言うと、素のままだと、PyCharm上で自動型推論がちゃんと効かないことがあったからです。
また、将来的にスクレイピング用ライブラリを差し替えたくなっても、ここだけ書き換えればOKという安心もあります。

さらに、Webサイトからデータを取得する部分についても、データベースと連携させてキャッシュする機構を組み込みました。
これにより、無駄なWebアクセスを避け、サーバーへの負荷を極限まで減らしています。
(`IDataBaseService`は自作クラス。詳細は書かないが、データベース操作をラップしたもの)

```python
class ScrapingService:
    """スクレイピング用のラッパークラス"""

    def __init__(self, database: IDataBaseService):
        self.session = HTMLSession()
        self.database = database
        self.database.query('CREATE TABLE IF NOT EXISTS page_cache (url TEXT PRIMARY KEY, text TEXT)')

    def get_page(self, url: str) -> DomObject:
        cache_data = self.database.select('SELECT text from page_cache WHERE url=?', (url,))
        if len(cache_data) == 0:
            temp: HTML = self.session.get(url).html
            time.sleep(5)
            print(f'caching... [{url}]')
            self.database.query('INSERT INTO page_cache (url, text) VALUES (?, ?)',
                                (url, temp.raw_html.decode(temp.encoding)))
            return DomObject(temp)
        else:
            return DomObject(HTML(html=cache_data[0]['text']))
```

# 正規表現処理についてもラップした

プログラミング言語により、正規表現の有無・操作方法は様々です。Pythonについてもこの点は変わりません。
ただ、素の状態だとちょっと冗長になるなーってことがあるので、よくラップして運用しています。

```python
def regex(text: str, pattern: str) -> List[str]:
    """グループ入り正規表現にマッチさせて、ヒットした場合はそれぞれの文字列の配列、そうでない場合は空配列を返す"""
    output: List[str] = []
    for m in re.finditer(pattern, text, re.MULTILINE):
        for x in m.groups():
            output.append(x)
    return output
```

これにより、例えば「`regex('24～70mm', r'(\d+)mm～(\d+)mm')`」と書いた場合、戻り値が「`['24', '70']`」となって扱いやすくなります。
また、「そのパターンとマッチしない＝配列の要素数が0件である」ということなので、条件分岐も効率よく記述できます。

```python
# 記述例
# ※Qiitaのソース埋め込みが壊れているので、「＼d」と書くと自動色分けが正常に動作しない
# ※そのため意図的に「＼＼d」と記している。適宜読み替えること

# 35mm判換算焦点距離
result1 = regex(record['35mm判換算焦点距離'], r'(\\d+)mm～(\\d+)mm')
result2 = regex(record['35mm判換算焦点距離'], r'(\\d+)mm')
if len(result1) > 0:
    wide_focal_length = int(result1[0])
    telephoto_focal_length = int(result1[1])
else:
    wide_focal_length = int(result2[0])
    telephoto_focal_length = wide_focal_length
```

# dataclassesは積極的に活用した

[dataclasses](https://docs.python.org/ja/3/library/dataclasses.html)とは、Python3.7から登場した、データクラスを手軽に作成できる仕組みのことです。今回も次のように、レンズ情報を記録するためのクラスとして活用しました。

```python
@dataclass
class Lens:
    id: int = 0
    maker: str = ''
    name: str = ''
    product_number: str = ''
    wide_focal_length: int = 0
    telephoto_focal_length: int = 0
    wide_f_number: float = 0
    telephoto_f_number: float = 0
    wide_min_focus_distance: float = 0
    telephoto_min_focus_distance: float = 0
    max_photographing_magnification: float = 0
    filter_diameter: float = 0
    is_drip_proof: bool = False
    has_image_stabilization: bool = False
    is_inner_zoom: bool = False
    overall_diameter: float = 0
    overall_length: float = 0
    weight: float = 0
    price: int = 0
    mount: str = ''
```

また、dataclassesだけだとJSONデータにシリアライズする処理が面倒なので、[dataclasses-json](https://pypi.org/project/dataclasses-json/)を追加導入して対処しています。

# フィルター処理における抽象化

当Webアプリでは、検索条件を追加すると、即座に画面下のレンズ一覧が書き換わる仕組みです。
この際、レンズ情報を各種条件でフィルターする処理が挟まっているのですが、フィルター処理をどう記述しようか迷いました。

例えば、真っ先に思いつくのは次のようなコードでしょう。

```TypeScript
// サンプルのフィルター設定
const filterList = [{'type': 'MaxWideFocalLength', 'value': 24, 'type': 'MinTelephotoFocalLength', 'value': 70}];

// フィルター処理
let temp = [...lensList];
for (const filter of filterList) {
  // switchで種類ごとに分岐
  switch (filter.type) {
    case 'MaxWideFocalLength':
      temp = temp.filter(lens => lens.wide_focal_length <= filter.value);
      break;
    case 'MinTelephotoFocalLength':
      temp = temp.filter(lens => lens.telephoto_focal_length >= filter.value);
      break;
  }
}
```

ただ、これだとフィルターの種類を増やすたびに、switch文がズラズラと連なることになります。**可読性が悪い**。
そこで、「フィルター処理を行う機構」をクラスにラップすることで解決を見ました。
また、「フィルター処理を行う機構」と「フィルターのパラメーター」を分離することで、前者の複雑度を軽減しています。

※実際のコードでは、QueryType型は他にもプロパティを生やしています

```TypeScript
// 「フィルター処理」を表現するための抽象クラス
abstract class QueryType {
  // フィルタ処理
  abstract filter(lensList: Lens[], value: number): Lens[];
}

// 個別のフィルター処理についての具象クラス
class MaxWideFocalLength implements QueryType {
  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.wide_focal_length <= value);
  }
}

class MinTelephotoFocalLength implements QueryType {
  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.telephoto_focal_length >= value);
  }
}

// 「1つのフィルター」を表現するためのインターフェース
interface Query {
  type: QueryType;
  value: number;
}

// サンプルのフィルター設定
const queryList: Query[] = [{'type': new MaxWideFocalLength(), 'value': 24, 'type': new MinTelephotoFocalLength(), 'value': 70}];

// フィルター処理
let temp = [...lensList];
for (const query of queryList) {
  temp = query.type.filter(temp, query.value);
}
```

# 抽象化の副次的作用

上記の`QueryType`ですが、実際のコードではより多くのプロパティが生えています。

```TypeScript
abstract class QueryType {
  // 型名
  abstract readonly name: string = '';
  // 数値部分の「手前」に表示するMessage
  abstract readonly prefixMessage: string;
  // 数値部分の「後」に表示するMessage
  abstract readonly suffixMessage: string;

  // フィルタ処理
  abstract filter(lensList: Lens[], value: number): Lens[];
}
```

これにより、例えば`MaxWideFocalLength`は次のような定義になっています。

```TypeScript
class MaxWideFocalLength implements QueryType {
  readonly name: string = 'MaxWideFocalLength';
  readonly prefixMessage: string = '広角端の換算焦点距離が';
  readonly suffixMessage: string = 'mm 以下';

  filter(lensList: Lens[], value: number): Lens[] {
    return lensList.filter(lens => lens.wide_focal_length <= value);
  }
}
```

こうした定義なのは、このアプリの性質上、「使用できるフィルターの一覧」を表示する需要があるからです。

---

`<select>`内に`<option>`を並べる場合、Reactだと次のように実装される方が多いと思います。

```TypeScript
const queryTypeList = [
  {type: 'MaxWideFocalLength', prefixMessage: '広角端の換算焦点距離が'},
  {type: 'MaxWideFocalLength', prefixMessage: '望遠端の換算焦点距離が'}];

return (
  <select>
    {queryTypeList.map(q => <option key={q.type} value={q.type}>{q.prefixMessage}</option>)}
  <select>
);
```

何も間違ってはいないのですが、このまま実装すると、`<select>`された値から、`MaxWideFocalLength`などの(`QueryType`を継承した型)を生成する際に**switch文を使う**ことになってしまいます。これでは先ほど頑張って排除した意味がありません。

そこで、型ごとに使いたいプロパティを埋め込んでおきます。
すると、`<select>`された値を`queryType`とした際、`queryTypeList.filter(q => q.name === queryType)[0]`とするだけで、所望の(`QueryType`を継承した)型のインスタンスを取得できます。**switch文なんて要らんかったんや！**

※この、「クエリの種類のインスタンス(フィルター処理を行う機構)を使い回せる」点が、「フィルター処理を行う機構」と「フィルターのパラメーター」を分離したご利益とも言えます

```TypeScript
const queryTypeList = [
  new MaxWideFocalLength(),
  new MaxWideFocalLength()
];

return (
  <select>
    {queryTypeList.map(q => <option key={q.name} value={q.name}>{q.prefixMessage}</option>)}
  <select>
);
```