Skip to main content

初嘗策略模式

紀錄寫爬蟲程式遇到的策略模式問題

原本只是想寫個簡單寫個爬蟲,想說順便學著使用各種程式碼品質工具,例如 ruff linter (語法檢查)和 mypy static typing (靜態型別檢查),然後為了解決型別檢查問題衍生出更多問題,就有了這篇文章。

策略模式:我的理解

說明:透過封裝實現多種不同實作,以單一個變數作為入口點,再看你想怎麼調用這個入口

使用情境:程式有某段邏輯需要根據不同情況選擇不同的處理方式

優點:方便隨時增加或變動實作,方便管理不同實作。

永遠記得說明和使用情境,網路上的文章廢話太多模糊了焦點。

問題描述

一開始很簡單的實現,這個 class,在 scrape_link 中基於 is_album_list 決定要調用 _process_album_list_links 還是 _process_album_image_links,每次調用會把該輸入的 URL 翻頁翻到底並擷取每頁結果:如果在相簿列表,獲取所有相簿網址才開始爬取相簿,scrape_link 回傳網址列表 list[str];否則回傳圖片網址和檔名列表 list[tuple[str, str]]。會這樣寫的原因是該網站只有兩種類型頁面,相簿列表和相簿本身,使用相同翻頁方式,所以覺得沒必要分成兩個 class method 來寫。

class LinkScraper:
"""Scrape logic."""

def __init__(self, web_bot, dry_run: bool, download_service, logger: logging.Logger):
# ...初始化

def scrape_link(
self,
url: str,
start_page: int,
is_album_list: bool,
# 注意這裡可能有兩種輸出型別!!!
) -> list[str] | list[tuple[str, str]]:
page_result: list[str] | list[tuple[str, str]] = []

while True:

# 根據 is_album_list 選擇調用哪種方法
if is_album_list:
self._process_album_list_links(page_links, page_result, page)
else:
self._process_album_image_links(page_links, page_result, alt_ctr, tree, page)

return page_result

def _process_album_list_links(
self,
page_links: list[str],
page_result: list[str], # 注意這裡和下面不同!!!
page: int,
):
"""Process and collect album URLs from list page."""
page_result.extend([BASE_URL + album_link for album_link in page_links])
self.logger.info("Found %d images on page %d", len(page_links), page)

def _process_album_image_links(
self,
page_links: list[str],
page_result: list[tuple[str, str]], # 注意這裡和上面不同!!!
alt_ctr: int,
tree: html.HtmlElement,
page: int,
):
"""Handle image links extraction and queueing for download."""
alts: list[str] = tree.xpath(XPATH_ALTS)

if len(alts) < len(page_links):
missing_alts = [str(i + alt_ctr) for i in range(len(page_links) - len(alts))]
alts.extend(missing_alts)
alt_ctr += len(missing_alts)

page_result.extend(zip(page_links, alts))

# Download file
if not self.dry_run:
album_name = self.extract_album_name(alts)
image_links = list(zip(page_links, alts))
self.download_service.add_download_task(album_name, image_links)
self.logger.info("Found %d images on page %d", len(page_links), page)

@staticmethod
def extract_album_name(alts: list[str]) -> str:
# ...Find the first non-digits element

但是使用 mypy 檢查時抱怨 incompatible type,因為我設定兩種不同輸出型別,兩者都會因為不符合對方而報錯。除此之外,後續處理返回的變數也一樣會被 mypy 抱怨。

error: Argument 2 to "_process_album_list_links" of "LinkScraper" has incompatible type "list[str] | list[tuple[str, str]]"; expected "list[str]"  [arg-type]
error: Argument 2 to "_process_album_image_links" of "LinkScraper" has incompatible type "list[str] | list[tuple[str, str]]"; expected "list[tuple[str, str]]" [arg-type]

想到的解決方式有這幾個

  1. 使用 isinstance 檢查,但是如果中間跨函式 mypy 一樣會抱怨,而且 list 包 tuple 要每項檢查很繁瑣。
  2. 使用增加一個新的 method 分流,想了十秒發現根本沒用,現在不就是同樣意思。
  3. page_result 改成 list[Any] 掩耳盜鈴。

同時也想到,如果該網站擴充新的頁面類型,以上方法基本只剩掩耳盜鈴法有用,現在的 scrape_link 結構也會造成維護困難,於是需要更好的解決方式。

解決方式

決定使用策略模式解決,策略模式就只是把「需要根據情況選擇的方法」封裝在獨立類別或函式中,再看你使用哪種方式選擇,使用組合而非繼承,這裡使用簡單的字典鍵值選擇。


class LinkScraper:
# Defines the mapping from string to scraping method.
SCRAPE_TYPE: ClassVar[dict[str, str]] = {
"ALBUM_LIST": "album_list",
"ALBUM_IMAGE": "album_image",
}

def __init__(self):
# 在這裡初始化所有策略
self.strategies: dict[str, ScrapingStrategy] = {
self.SCRAPE_TYPE["ALBUM_LIST"]: AlbumListStrategy(config),
self.SCRAPE_TYPE["ALBUM_IMAGE"]: AlbumImageStrategy(config),
}


def _scrape_link(
self,
url: str,
start_page: int,
scraping_type: str,
**kwargs,
)
# 根據字串選擇使用哪種策略
strategy = self.strategies[scraping_type]
while True:
# ...省略

# 原版程式碼
# if is_album_list:
# self._process_album_list_links(page_links, page_result, page)
# else:
# self._process_album_image_links(page_links, page_result, alt_ctr, tree, page)

# 新版程式碼
strategy.process_page_links(page_links, page_result, tree, page)

新版程式碼在每次呼叫 _scrape_link 時都根據輸入字串選擇要使用的解析方式,省略了 if-else 條件判斷,也減輕 LinkScraper 負擔,把工作侷限程式碼的上下文接口,調用策略,每個策略各自處理該如何解析 html 檔案,可以輕易新增或移除策略。除此之外策略模式的好處還有如果遇到複雜的頁面,需要更多子函式來處理,也不用把分散的 function 全部都匯集到一個 function 中,可以都在各自的 xxxStratedy 類別自行管理。

註:純粹的策略模式不包含實例化部分,這個範例包含實例化,實作時區分這些不是很重要,但是既然寫成文章就要清楚說明。




雖然策略模式到這邊就結束了,但是最開始的 mypy 型別檢查的問題好像還沒解決欸?沒錯各位被我紅鯡魚了,策略模式和解決後續調用輸出結果的 incompatible type 沒有關係。解決型別檢查問題的很簡單,只要把 type hint 改成 Literal 就解決了。方法很簡單,但是我花了很久才找到這個方法,然後 Literal 挺酷的,第一次遇到打字串會有 IDE 自動補齊功能。

ScrapeStrategy = Literal["album_list", "album_image"]


class ScrapeHandler:
"""Handles all scraper behaviors."""

# Defines the mapping from url part to scrape method.
SCRAPE_TYPE: ClassVar[dict[str, ScrapeStrategy]] = {
"album": "album_image",
"actor": "album_list",
}

def __init__(self, runtime_config: RuntimeConfig, base_config: Config, web_bot):
self.web_bot = web_bot
self.logger = runtime_config.logger
self.runtime_config = runtime_config
self.strategies: dict[ScrapeStrategy, BaseScraper] = {
"album_list": AlbumScraper(runtime_config, base_config, web_bot),
"album_image": ImageScraper(runtime_config, base_config, web_bot),
}

def _scrape_link(
self,
url: str,
start_page: int,
scrape_type: ScrapeType,
**kwargs,
) -> list[str] | list[tuple[str, str]]:

原版的超醜解法

細心的讀者可能已經發現 _scrape_link 變成私有函數,這邊使用幾個緩衝 method 解決型別的問題,暫時只能想到這個很醜的方式,未來如果找到更好方式再更新。

def buffer_album_list(
self,
url: str,
start_page: int,
**kwargs
) -> list[str]:
"""Entry and buffer method for album list scraping."""
return self._scrape_link(url, start_page, self.SCRAPE_TYPE["ALBUM_LIST"], **kwargs)

def buffer_album_images(
self,
url: str,
start_page: int,
**kwargs
) -> list[tuple[str, str]]:
"""Entry and buffer method for Album images scraping."""
return self._scrape_link(url, start_page, self.SCRAPE_TYPE["ALBUM_IMAGE"], **kwargs)

心得感想

沒想到會用到策略模式,除此之外還學了 typing 工具,例如 ClassVar, Generic, TypeAlias, TypeVar。其中還發現了模板方法模式、外觀模式,感覺滿硬要的,正常人寫程式自動就會變成這些模式,沒必要去死記這些,就像以前電磁學第一大題都是名詞解釋,考完根本沒有理解核心,不過電磁學是因為太難需要送分就是了。另外,平衡 spaghetti code 和 ravioli code,以及程式優化的時機也是個學問,太早優化後面要改更痛苦,太晚優化中間開發很卡。

後話

寫的當下就覺得有點 over-design 了,現在想想確實如此,一開始直接寫 type-hint 是 list 就好,把 type-hint 寫的這麼詳細反而浪費了 Python 方便的特性,尤其是在這麼小的項目上,不過這本來就是一個練習專案,當作練習剛剛好囉。

後話之二:文章寫完隔幾天刷到這個影片:策略模式?代码的自然演化,仅此而已…,這麼頻繁看到策略模式,難道這就是我的天使模式嗎?好了不鬧,他光是標題就說的很好,這只是自然演化,不必死記硬背,死背只是讓自己變成考試超人而已。