概要
この度、交換用レンズの情報について、条件を指定して検索できるツールを開発・公開しました。
自作のマイクロフォーサーズ用レンズデータベース検索Webアプリを、Firebaseでデプロイしました。
— YSR@あいミス10章クリア (@YSRKEN) August 30, 2020
追加した検索条件は、同種の条件を追加すると上書きされ、条件自体をクリックすると削除されます。
また、詳細ボタンからレンズの詳細データを確認できます。https://t.co/TRta0pX3H1 pic.twitter.com/5hQpjaOBiM
【お知らせ】レンズを検索できるデータベースを更新しました。検索条件をシェアしたり、クリップボードにコピーしたりできるように!
— YSR@あいミス10章クリア (@YSRKEN) September 16, 2020
レンズデータベース(マイクロフォーサーズ, ライカL マウント向け。スマホ対応!) https://t.co/TRta0pX3H1 pic.twitter.com/l5ZA6q9XET
今回は、その際に工夫したことについてのまとめです。
スクレイピング用ライブラリは適宜ラップした
今回のWebアプリでは、レンズについての情報はJSONファイルとして運用していました。
ただ、各レンズの情報を全て手打ちしたわけではありません。
PythonでWebサイトをスクレイピングし、結果をJSONファイルに保存して、フロントエンド側で読み込ませていました。
……その際に使ったライブラリは、requests-HTMLです。と言っても、そのまま使うのではなく、別途クラスを作成してそちらに処理をまとめています。
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
は自作クラス。詳細は書かないが、データベース操作をラップしたもの)
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についてもこの点は変わりません。
ただ、素の状態だとちょっと冗長になるなーってことがあるので、よくラップして運用しています。
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件である」ということなので、条件分岐も効率よく記述できます。
# 記述例
# ※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とは、Python3.7から登場した、データクラスを手軽に作成できる仕組みのことです。今回も次のように、レンズ情報を記録するためのクラスとして活用しました。
@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を追加導入して対処しています。
フィルター処理における抽象化
当Webアプリでは、検索条件を追加すると、即座に画面下のレンズ一覧が書き換わる仕組みです。
この際、レンズ情報を各種条件でフィルターする処理が挟まっているのですが、フィルター処理をどう記述しようか迷いました。
例えば、真っ先に思いつくのは次のようなコードでしょう。
// サンプルのフィルター設定
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型は他にもプロパティを生やしています
// 「フィルター処理」を表現するための抽象クラス
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
ですが、実際のコードではより多くのプロパティが生えています。
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
は次のような定義になっています。
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だと次のように実装される方が多いと思います。
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文なんて要らんかったんや!
※この、「クエリの種類のインスタンス(フィルター処理を行う機構)を使い回せる」点が、「フィルター処理を行う機構」と「フィルターのパラメーター」を分離したご利益とも言えます
const queryTypeList = [
new MaxWideFocalLength(),
new MaxWideFocalLength()
];
return (
<select>
{queryTypeList.map(q => <option key={q.name} value={q.name}>{q.prefixMessage}</option>)}
<select>
);