本博文將會透過一個網路爬蟲的例子,向你介紹 Kotlin 的基本用法和其簡潔有力的 DSL。
關於DSL
按照維基百科的說法,DSL(domain-specific language) 是一種專注於某一特定應用領域的計算機語言。和我們常用的通用目的型語言(類如 C,Java,Python 等)相反,DSL 並不承諾可用來解決一切可計算性問題。DSL 設計者聚焦於某一特定的場景,透過對 DSL 的精心設計,讓使用者在這一場景下能夠用該 DSL 簡潔高效地表達出自己的想法。例如在資料庫領域,SQL 就是一種被用作“查詢”的 DSL;在 Web 開發領域,用 HTML 這種 DSL 來描述一張網頁的佈局結構。而本文介紹的 Kotlin DSL,它是 Kotlin 提供的一種建立 DSL 的能力。我們可以很容易藉助該能力建立我們自己的 DSL,例如,Jetpack Compose,Gradle’s Kotlin DSL。
Kotlin DSL
Kotlin DSL 的能力主要來自於 Kotlin 的如下幾個語法特性:
- Lambda表示式,包括
- 高階函式
- 函式的最後一個引數是函式時,可以將函式提取到括號的外面
- 單引數函式用 it 作為引數的預設名字,可不用宣告
- 運算子過載
- 中綴符
- 擴充套件函式
快速開始
我們首先設計爬蟲程式的 API,即 DSL 的語法。以爬取本部落格站點的全部博文為例,我們希望爬蟲程式完成後,使用者可以這麼去呼叫:
val spider = Spider("https://www.cnblogs.com/dongkuo") {
html {
// 文章詳情頁
follow(".postTitle2:eq(0)") {
val article = htmlExtract<Article> {
it.url = this@follow.request.url.toString()
it.title = css("#cb_post_title_url")?.text()
}
// 下載文章
download("./blogs/${article.title}.html")
}
// 下一頁
follow("#nav_next_page a")
follow("#homepage_bottom_pager a:containsOwn(下一頁)")
}
}
spider.start()
data class Article(var url: String? = null, var title: String? = null)
以上程式碼的大致邏輯是:首先透過呼叫 Spider
構造方法建立一隻爬蟲,並指定一個初始待爬取的 url,然後啟動。透過呼叫 html
方法或 htmlExtract
方法,可將請求的響應體解析成 html 文件,接著可以呼叫 follow
方法“跟隨”某些 html 標籤的連結(繼續爬取這些連結),也可以呼叫 download
方法下載響應內容到檔案中。
下面按各個類去介紹如何實現上述 DSL。
Spider 類
Spider
類代表爬蟲,呼叫其建構函式時可以指定初始的 url 和爬蟲的配置資訊;Spider 建構函式的最後一個引數是一個函式,用於處理請求初始 url 的響應或作為提交 url 時未指定 handler 的預設 handler。其接收者,即該函式作用域內的 this 為 Response
物件。利用函式的最後一個引數是函式時的便利寫法,我們可以把該函式的函式體提到引數括號的外面。因此,原本的 Spider("https://www.cnblogs.com/dongkuo", defaultHandler = {})
變為 Spider("https://www.cnblogs.com/dongkuo"){}
。
Spider 類提供 addUrls
方法,用於向爬蟲提交需要爬取的網頁:
class Spider(
vararg startUrls: String,
private val options: Options = Options(),
private val defaultHandler: Handler<Response>
) {
private val taskChannel: Channel<Task> = Channel(Channel.UNLIMITED)
suspend fun addUrls(vararg urls: String, handler: Handler<Response> = defaultHandler) {
urls.forEach {
log.debug("add url: $it")
taskChannel.send(Task(it, handler))
}
}
}
typealias Handler<T> = suspend (T).() -> Unit
typealias ExtraHandler<T, E> = suspend (T).(E) -> Unit
data class Task(val url: String, val handler: Handler<Response>)
Spider 的 start
方法會建立若干 Fetcher 去爬取網頁,此過程用協程執行:
@OptIn(ExperimentalCoroutinesApi::class)
fun start(stopAfterFinishing: Boolean = true) {
updateState(State.NEW, State.RUNNING) {
// launch fetcher
val fetchers = List(options.fetcherNumber) { Fetcher(this) }
for (fetcher in fetchers) {
launch {
fetcher.start()
}
}
// wait all fetcher idle and task channel is empty
runBlocking {
var allIdleCount = 0
while (true) {
val isAllIdle = fetchers.all { it.isIdle }
if (isAllIdle && taskChannel.isEmpty) {
allIdleCount++
} else {
allIdleCount = 0
}
if (allIdleCount == 2) {
fetchers.forEach { it.stop() }
return@runBlocking
}
delay(1000)
}
}
}
}
Fetcher 類
Fetcher
類用於從 channel
中取出請求任務並執行,最後呼叫 handler 方法處理請求響應:
private class Fetcher(val spider: Spider) {
var isIdle = true
private set
private var job: Job? = null
suspend fun start() = withContext(spider.coroutineContext) {
job = launch(CoroutineName("${spider.options.spiderName}-fetcher")) {
while (true) {
isIdle = true
val task = spider.taskChannel.receive()
isIdle = false
spider.log.debug("fetch ${task.url}")
val httpStatement = spider.httpClient.prepareGet(task.url) {
timeout {
connectTimeoutMillis = spider.options.connectTimeoutMillis
requestTimeoutMillis = spider.options.requestTimeoutMillis
socketTimeoutMillis = spider.options.socketTimeoutMillis
}
}
httpStatement.execute {
val request = Request(URI.create(task.url).toURL(), "GET")
task.handler.invoke(Response(request, it, spider))
}
}
}
}
fun stop() {
job?.cancel()
}
}
Response 類
Response
類代表請求的響應,它有獲取響應碼、響應頭的方法。
fun statusCode(): Int {
TODO()
}
fun header(name: String): String? {
TODO()
}
// ...
除此之外,我們還需要一些解析響應體的方法來方便使用者處理響應。因此提供
text
方法:將響應體編碼成字串;html
方法:將響應體解析成 html 文件(見Document
類);htmlExtra
方法:將響應體解析成 html 文件,並自動建立透過泛型指定的資料類返回。它的末尾引數是一個函式,其作用域內,it
指向自動建立(透過反射建立)的資料物件,this
指向Document
物件。stream
方法:獲取響應體的輸入流;download
方法:儲存響應體資料到檔案;
具體實現程式碼可在文末給出的倉庫中找到。
Selectable 與 Extractable 介面
Selectable
介面表示“可選擇”元素的,定義了若干選擇元素的方法:
interface Selectable {
fun css(selector: String): Element?
fun cssAll(selector: String): List<Element>
fun xpath(selector: String): Element?
fun xpathAll(selector: String): List<Element>
fun firstChild(): Element?
fun lastChild(): Element?
fun nthChild(index: Int): Element?
fun children(): List<Element>
}
Extractable
介面表示“可提取”資訊的,定義了若干提取資訊的方法:
interface Extractable {
fun tag(): String?
fun html(onlyInner: Boolean = false): String?
fun text(onlyOwn: Boolean = false): String?
fun attribute(name: String, absoluteUrl: Boolean = true): String
}
為了方便使用,還定義一個函式型別的別名 Extractor
:
typealias Extractor = (Extractable?) -> String?
並提供一些便利地建立 Extractor
函式的函式(高階函式):
fun tag(): Extractor = { it?.tag() }
fun html(): Extractor = { it?.html() }
fun attribute(name: String): Extractor = { it?.attribute(name) }
fun text(): Extractor = { it?.text() }
Document 類
Document
類代表 HTML 文件。它實現了 Selectable
介面:
class Document(
html: String,
baseUrl: String,
private val spider: Spider
) : Selectable {
fun title(): String {
TODO()
}
override fun css(selector: String): Element? {
TODO()
}
// ...
}
除此以外,Document
類還提供 follow
方法,便於使用者能快速跟隨頁面中的連結:
suspend fun follow(
css: String? = null,
xpath: String? = null,
extractor: Extractor = attribute("href"),
handler: Handler<Response>? = null
) {
if (css != null) {
follow(cssAll(css), extractor, handler)
}
if (xpath != null) {
follow(xpathAll(xpath), extractor, handler)
}
}
suspend fun follow(
extractableList: List<Extractable>,
extractor: Extractor = attribute("href"),
responseHandler: Handler<Response>? = null
) {
extractableList.forEach { follow(it, extractor, responseHandler) }
}
suspend fun follow(
extractable: Extractable?,
extractor: Extractor = attribute("href"),
handler: Handler<Response>? = null
) {
val url = extractable.let(extractor) ?: return
if (handler == null) {
spider.addUrls(url)
} else {
spider.addUrls(url, handler = handler)
}
}
Element 類
Element
類代表 DOM 中的元素。它除了具有和 Document
類一樣的讀取 DOM 的方法外(實現 Selectable
介面),還實現了Extractable
介面:
class Element(private val innerElement: InnerElement) : Selectable, Extractable {
// ...
}
總結
本文試圖透過一個簡單的爬蟲程式向讀者展示 Kotlin 以及 其 DSL 的魅力。作為一門 JVM 語言,Kotlin 在遵守 JVM 平臺規範的基礎上,吸取了眾多優秀的語法特性,值得大家嘗試。
本文完整程式碼可在 kspider 倉庫中找到。