从API到DSL —— 使用 Kotlin 特性为爬虫框架进一步封装

Java与Android技术栈2019-02-13 10:35:04


NetDiscovery (https://github.com/fengzhizi715/NetDiscovery)  是一款基于 Vert.x、RxJava 2 等框架实现的爬虫框架。

一. 如何创建 DSL

领域特定语言(英语:domain-specific language、DSL)指的是专注于某个应用程序领域的计算机语言。又译作领域专用语言。DSL 能够简化程序设计过程,提高生产效率的技术,同时也让非编程领域专家的人直接描述逻辑成为可能。

NetDiscovery 本身提供了很多功能的 API,然而它的 DSL 模块是为了让使用者拥有更多的选择。

本文讨论的 DSL 是内部 DSL。

内部 DSL:通用语言的特定语法,用内部DSL写成的脚本是一段合法的程序,但是它具有特定的风格,而且仅仅用到了语言的一部分特性,用于处理整个系统一个小方面的问题。

NetDiscovery 的 DSL 主要是结合 Kotlin 带接收者的 Lambda、运算符重载、中缀表达式等 Kotlin 语法特性来编写。

运算符重载、中缀表达式其实很多语言都有,那么我们着重介绍一下带接收者的 Lambda。

在介绍 Kotlin 带接收者的 Lambda 之前,先介绍一下带接收者的函数类型。

带接收者的函数类型,例如 A.(B) -> C,其中 A 是接收者类型,B是参数类型,C是返回类型。

例如:

  1.    val sum: Int.(Int) -> Int = {

  2.        this + it

  3.    }

sum 是带接收者的函数类型,它在使用上类似于扩展函数。在函数内部,可以使用this指代传给调用的接收者对象。

而带接收者的 Lambda 典型代表是 Kotlin 标准库的扩展函数:with 和 apply。

看一下 apply 的源码:

  1. public inline fun <T> T.apply(block: T.() -> Unit): T {

  2.    contract {

  3.        callsInPlace(block, InvocationKind.EXACTLY_ONCE)

  4.    }

  5.    block()

  6.    return this

  7. }

在 apply 函数中,参数 block 是一个带有接收者的函数类型的参数。

对于 apply 函数的使用,先定义一个 User 对象:

  1. class User{

  2.    var name:String?=null

  3.    var password: String?=null

  4.    override fun toString(): String {

  5.        return "name:$name,password=$password"

  6.    }

  7. }

然后,使用 apply 函数对 User 的属性进行赋值:

  1. fun main(args: Array<String>) {

  2.    val user = User().apply {

  3.        name = "Tony"

  4.        password = "123456"

  5.    }

  6.    println(user)

  7. }

二. Request 的 DSL 封装

Request 请求包含了爬虫网络请求 Request 的封装,例如:url、userAgent、httpMethod、header、proxy 等等。当然,还包含了请求发生之前、之后做的一些事情,类似于AOP。

那么,我们来看一下使用 DSL 来编写Request:

  1.        val request = request {

  2.            url = "https://www.baidu.com/"

  3.            httpMethod = HttpMethod.GET

  4.            spiderName = "tony"

  5.            header {

  6.                "111" to "2222"

  7.                "333" to "44444"

  8.            }

  9.            extras {

  10.                "tt" to "qqq"

  11.            }

  12.        }

  13.        Spider.create().name("tony").request(request).pipeline(DebugPipeline()).run()

可以看到,Request 使用 DSL 封装之后,非常简单明了。

下面的代码是具体的实现,主要是使用带接收者的 Lambda、中缀表达式。

  1. package com.cv4j.netdiscovery.dsl

  2. import com.cv4j.netdiscovery.core.domain.Request

  3. import io.vertx.core.http.HttpMethod

  4. /**

  5. * Created by tony on 2018/9/18.

  6. */

  7. class RequestWrapper {

  8.    private val headerContext = HeaderContext()

  9.    private val extrasContext = ExtrasContext()

  10.    var url: String? = null

  11.    var spiderName: String? = null

  12.    var httpMethod: HttpMethod = HttpMethod.GET

  13.    fun header(init: HeaderContext.() -> Unit) {

  14.        headerContext.init()

  15.    }

  16.    fun extras(init: ExtrasContext.() -> Unit) {

  17.        extrasContext.init()

  18.    }

  19.    internal fun getHeaderContext() = headerContext

  20.    internal fun getExtrasContext() = extrasContext

  21. }

  22. class HeaderContext {

  23.    private val map: MutableMap<String, String> = mutableMapOf()

  24.    infix fun String.to(v: String) {

  25.        map[this] = v

  26.    }

  27.    internal fun forEach(action: (k: String, v: String) -> Unit) = map.forEach(action)

  28. }

  29. class ExtrasContext {

  30.    private val map: MutableMap<String, Any> = mutableMapOf()

  31.    infix fun String.to(v: Any) {

  32.        map[this] = v

  33.    }

  34.    internal fun forEach(action: (k: String, v: Any) -> Unit) = map.forEach(action)

  35. }

  36. fun request(init: RequestWrapper.() -> Unit): Request {

  37.    val wrap = RequestWrapper()

  38.    wrap.init()

  39.    return configRequest(wrap)

  40. }

  41. private fun configRequest(wrap: RequestWrapper): Request {

  42.    val request =  Request(wrap.url).spiderName(wrap.spiderName).httpMethod(wrap.httpMethod)

  43.    wrap.getHeaderContext().forEach { k, v ->

  44.        request.header(k,v)

  45.    }

  46.    wrap.getExtrasContext().forEach { k, v ->

  47.        request.putExtra(k,v)

  48.    }

  49.    return request

  50. }

三. SpiderEngine的 DSL 封装

SpiderEngine 可以管理引擎中的爬虫,包括爬虫的生命周期。

下面的例子展示了创建一个 SpiderEngine,并往 SpiderEngine 中添加2个爬虫(Spider)。其中一个爬虫是定时地去请求网页。

  1.        val spiderEngine = spiderEngine {

  2.            port = 7070

  3.            addSpider {

  4.                name = "tony1"

  5.            }

  6.            addSpider {

  7.                name = "tony2"

  8.                urls = listOf("https://www.baidu.com")

  9.            }

  10.        }

  11.        val spider = spiderEngine.getSpider("tony1")

  12.        spider.repeatRequest(10000,"https://github.com/fengzhizi715")

  13.                .initialDelay(10000)

  14.        spiderEngine.runWithRepeat()

四. Selenium 模块的 DSL 封装

在我之前的文章为爬虫框架构建Selenium模块、DSL模块(Kotlin实现) 中,曾举例使用 NetDiscovery 的 Selenium 模块实现:在京东上搜索我的新书《RxJava 2.x 实战》,并按照销量进行排序,然后获取前十个商品的信息。

这次,使用 DSL 来实现这个功能:

  1.        spider {

  2.            name = "jd"

  3.            urls = listOf("https://search.jd.com/")

  4.            downloader = seleniumDownloader {

  5.                path = "example/chromedriver"

  6.                browser = Browser.CHROME

  7.                addAction {

  8.                    action = BrowserAction()

  9.                }

  10.                addAction {

  11.                    action = SearchAction()

  12.                }

  13.                addAction {

  14.                    action = SortAction()

  15.                }

  16.            }

  17.            parser = PriceParser()

  18.            pipelines = listOf(PricePipeline())

  19.        }.run()

这里,主要是对 SeleniumDownloader 的封装。Selenium 模块可以适配多款浏览器,而 Downloader 是爬虫框架的下载器组件,实现具体网络请求的功能。这里的 DSL 需要封装所使用的浏览器、浏览器驱动地址、各个模拟浏览器动作(Action)等。

  1. package com.cv4j.netdiscovery.dsl

  2. import com.cv4j.netdiscovery.selenium.Browser

  3. import com.cv4j.netdiscovery.selenium.action.SeleniumAction

  4. import com.cv4j.netdiscovery.selenium.downloader.SeleniumDownloader

  5. import com.cv4j.netdiscovery.selenium.pool.WebDriverPool

  6. import com.cv4j.netdiscovery.selenium.pool.WebDriverPoolConfig

  7. /**

  8. * Created by tony on 2018/9/14.

  9. */

  10. class SeleniumWrapper {

  11.    var path: String? = null

  12.    var browser: Browser? = null

  13.    private val actions = mutableListOf<SeleniumAction>()

  14.    fun addAction(block: ActionWrapper.() -> Unit) {

  15.        val actionWrapper = ActionWrapper()

  16.        actionWrapper.block()

  17.        actionWrapper?.action?.let {

  18.            actions.add(it)

  19.        }

  20.    }

  21.    internal fun getActions() = actions

  22. }

  23. class ActionWrapper{

  24.    var action:SeleniumAction?=null

  25. }

  26. fun seleniumDownloader(init: SeleniumWrapper.() -> Unit): SeleniumDownloader {

  27.    val wrap = SeleniumWrapper()

  28.    wrap.init()

  29.    return configSeleniumDownloader(wrap)

  30. }

  31. private fun configSeleniumDownloader(wrap: SeleniumWrapper): SeleniumDownloader {

  32.    val config = WebDriverPoolConfig(wrap.path, wrap.browser)

  33.    WebDriverPool.init(config)

  34.    return SeleniumDownloader(wrap.getActions())

  35. }

除此之外,还对 WebDriver 添加了一些常用的扩展函数。例如:

  1. fun WebDriver.elementByXpath(xpath: String, init: WebElement.() -> Unit) = findElement(By.xpath(xpath)).init()

这样的好处是简化WebElement的操作,例如下面的 BrowserAction :打开浏览器输入关键字

  1. package com.cv4j.netdiscovery.example.jd;

  2. import com.cv4j.netdiscovery.selenium.Utils;

  3. import com.cv4j.netdiscovery.selenium.action.SeleniumAction;

  4. import org.openqa.selenium.WebDriver;

  5. import org.openqa.selenium.WebElement;

  6. /**

  7. * Created by tony on 2018/6/12.

  8. */

  9. public class BrowserAction extends SeleniumAction{

  10.    @Override

  11.    public SeleniumAction perform(WebDriver driver) {

  12.        try {

  13.            String searchText = "RxJava 2.x 实战";

  14.            String searchInput = "//*[@id=\"keyword\"]";

  15.            WebElement userInput = Utils.getWebElementByXpath(driver, searchInput);

  16.            userInput.sendKeys(searchText);

  17.            Thread.sleep(3000);

  18.        } catch (InterruptedException e) {

  19.            e.printStackTrace();

  20.        }

  21.        return null;

  22.    }

  23. }

而使用了 WebDriver 的扩展函数之后,上述代码等价于下面的代码:

  1. package com.cv4j.netdiscovery.example.jd

  2. import com.cv4j.netdiscovery.dsl.elementByXpath

  3. import com.cv4j.netdiscovery.selenium.action.SeleniumAction

  4. import org.openqa.selenium.WebDriver

  5. /**

  6. * Created by tony on 2018/9/23.

  7. */

  8. class BrowserAction2 : SeleniumAction() {

  9.    override fun perform(driver: WebDriver): SeleniumAction? {

  10.        try {

  11.            val searchText = "RxJava 2.x 实战"

  12.            val searchInput = "//*[@id=\"keyword\"]"

  13.            driver.elementByXpath(searchInput){

  14.                this.sendKeys(searchText)

  15.            }

  16.            Thread.sleep(3000)

  17.        } catch (e: InterruptedException) {

  18.            e.printStackTrace()

  19.        }

  20.        return null

  21.    }

  22. }

五. 总结

爬虫框架github地址:https://github.com/fengzhizi715/NetDiscovery

这里使用的 DSL 很多情况是对链式调用的进一步封装。当然,有人会更喜欢链式调用,也有人会更喜欢 DSL。但是从 API 到 DSL,个人明细更加喜欢 DSL 的风格。


关注【Java与Android技术栈】

更多精彩内容请关注扫码


Copyright © 温县电话机虚拟社区@2017