使用Kotlin Coroutines简单改造原有的爬虫框架

日落的风景.jpg

NetDiscovery 是一款基于 Vert.x、RxJava2 实现的爬虫框架。因为我最近正好在学习 Kotlin 的 Coroutines,在学习过程中尝试改造一下自己的爬虫框架。所以,我为它新添加了一个模块:coroutines 模块。

一. 爬虫框架的基本原理:

对于单个爬虫而言,从消息队列 queue 中获取 request,然后通过下载器 downloader 完成网络请求并获得 html 的内容,通过解析器 parser 解析 html 的内容,然后由多个 pipeline 按照顺序执行操作。其中,downloader、queue、parser、pipeline 这些组件都是接口,爬虫框架里内置了它们很多实现。开发者可以根据自身情况来选择使用或者自己开发全新的实现。

basic_principle.png

下面响应式风格的代码反映了上图爬虫框架的基本原理:

                    // 从消息队列中取出request
                    final Request request = queue.poll(name);

                    ......

                    // request正在处理
                    downloader.download(request)
                            .map(new Function<Response, Page>() {

                                @Override
                                public Page apply(Response response) throws Exception {

                                    Page page = new Page();
                                    page.setRequest(request);
                                    page.setUrl(request.getUrl());
                                    page.setStatusCode(response.getStatusCode());

                                    if (Utils.isTextType(response.getContentType())) { // text/html

                                        page.setHtml(new Html(response.getContent()));

                                        return page;
                                    } else if (Utils.isApplicationJSONType(response.getContentType())) { // application/json

                                        // 将json字符串转化成Json对象,放入Page的"RESPONSE_JSON"字段。之所以转换成Json对象,是因为Json提供了toObject(),可以转换成具体的class。
                                        page.putField(Constant.RESPONSE_JSON,new Json(new String(response.getContent())));

                                        return page;
                                    } else if (Utils.isApplicationJSONPType(response.getContentType())) { // application/javascript

                                        // 转换成字符串,放入Page的"RESPONSE_JSONP"字段。
                                        // 由于是jsonp,需要开发者在Pipeline中自行去掉字符串前后的内容,这样就可以变成json字符串了。
                                        page.putField(Constant.RESPONSE_JSONP,new String(response.getContent()));

                                        return page;
                                    } else {

                                        page.putField(Constant.RESPONSE_RAW,response.getIs()); // 默认情况,保存InputStream

                                        return page;
                                    }
                                }
                            })
                            .map(new Function<Page, Page>() {

                                @Override
                                public Page apply(Page page) throws Exception {

                                    if (parser != null) {

                                        parser.process(page);
                                    }

                                    return page;
                                }
                            })
                            .map(new Function<Page, Page>() {

                                @Override
                                public Page apply(Page page) throws Exception {

                                    if (Preconditions.isNotBlank(pipelines)) {

                                        pipelines.stream()
                                                .forEach(pipeline -> pipeline.process(page.getResultItems()));
                                    }

                                    return page;
                                }
                            })
                            .observeOn(Schedulers.io())
                            .subscribe(new Consumer<Page>() {

                                @Override
                                public void accept(Page page) throws Exception {

                                    log.info(page.getUrl());

                                    if (request.getAfterRequest()!=null) {

                                        request.getAfterRequest().process(page);
                                    }
                                }
                            }, new Consumer<Throwable>() {
                                @Override
                                public void accept(Throwable throwable) throws Exception {

                                    log.error(throwable.getMessage());
                                }
                            });

其中,Downloader的download方法会返回一个Maybe<Response>。

import com.cv4j.netdiscovery.core.domain.Request;
import com.cv4j.netdiscovery.core.domain.Response;
import io.reactivex.Maybe;

import java.io.Closeable;

/**
 * Created by tony on 2017/12/23.
 */
public interface Downloader extends Closeable {

    Maybe<Response> download(Request request);
}

正是因为这个 Maybe<Response> 对象,后续的一系列的链式调用才显得非常自然。比如将Response转换成Page对象,再对Page对象进行解析,Page解析完毕之后做一系列的pipeline操作。

当然,在爬虫框架里还有 SpiderEngine 可以管理 Spider。

二. 使用协程改造

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

由于 Kotlin Coroutines 仍然是试验的API,所以我不打算在爬虫框架原有的 core 模块上进行改动。于是,新增一个模块。

在新模块里,将之前的响应式风格的代码,改造成协程的方式。

Kotlin Coroutines 为各种基于 reactive streams 规范的库提供了工具类。可以在下面的github地址找到。

https://github.com/Kotlin/kotlinx.coroutines/tree/master/reactive

我在build.gradle中添加了

    compile 'org.jetbrains.kotlinx:kotlinx-coroutines-core:0.23.0'
    compile 'org.jetbrains.kotlinx:kotlinx-coroutines-rx2:0.23.0'

注意,协程的版本号必须跟 Kotlin 的版本要相符和。我所使用的 Kotlin 的版本是1.2.41

下面是修改之后的 Kotlin 代码,原有的各种组件接口依然可以使用。

                       // 从消息队列中取出request
                       final Request request = queue.poll(name);

                        ......

                        // request正在处理
                        val download = downloader.download(request).await()

                        download?.run {

                            val page = Page()
                            page.request = request
                            page.url = request.url
                            page.statusCode = statusCode

                            if (Utils.isTextType(contentType)) { // text/html

                                page.html = Html(content)
                            } else if (Utils.isApplicationJSONType(contentType)) { // application/json

                                // 将json字符串转化成Json对象,放入Page的"RESPONSE_JSON"字段。之所以转换成Json对象,是因为Json提供了toObject(),可以转换成具体的class。
                                page.putField(Constant.RESPONSE_JSON, Json(String(content)))
                            } else if (Utils.isApplicationJSONPType(contentType)) { // application/javascript

                                // 转换成字符串,放入Page的"RESPONSE_JSONP"字段。
                                // 由于是jsonp,需要开发者在Pipeline中自行去掉字符串前后的内容,这样就可以变成json字符串了。
                                page.putField(Constant.RESPONSE_JSONP, String(content))
                            } else {

                                page.putField(Constant.RESPONSE_RAW, `is`) // 默认情况,保存InputStream
                            }

                            page
                        }?.apply {

                            if (parser != null) {

                                parser!!.process(this)
                            }

                        }?.apply {

                            if (Preconditions.isNotBlank(pipelines)) {

                                pipelines.stream()
                                        .forEach { pipeline -> pipeline.process(resultItems) }
                            }

                        }?.apply {

                            println(url)

                            if (request.afterRequest != null) {

                                request.afterRequest.process(this)
                            }
                        }

其中,download 变量返回了 Maybe<Response> 的结果。之后, run、apply 等 Kotlin 标准库的扩展函数替代了原先的 RxJava 的 map 操作。

Kotlin 的协程是无阻塞的异步编程方式。上面看似同步的代码,其实是异步实现的。

await() 方法是 Maybe 的扩展函数:

/**
 * Awaits for completion of the maybe without blocking a thread.
 * Returns the resulting value, null if no value was produced or throws the corresponding exception if this
 * maybe had produced error.
 *
 * This suspending function is cancellable.
 * If the [Job] of the current coroutine is cancelled or completed while this suspending function is waiting, this function
 * immediately resumes with [CancellationException].
 */
@Suppress("UNCHECKED_CAST")
public suspend fun <T> MaybeSource<T>.await(): T? = (this as MaybeSource<T?>).awaitOrDefault(null)

由于 await() 方法是suspend修饰的,所以在上述代码的最外层还得加上一段代码,来创建协程。

        runBlocking(CommonPool) {
                    ......
        }

到此,完成了最初的改造,感兴趣的同学可以查看我的爬虫框架。
github地址:https://github.com/fengzhizi715/NetDiscovery

三. 小结

随着 Kotlin Coroutines 未来的正式发布,爬虫框架的 coroutines 模块也会考虑合并到 core 模块中。以及随着个人对 Kotlin Coroutines 的进一步认识和理解,也会考虑在更多的地方使用 Coroutines ,例如 Vert.x 和 Kotlin Coroutines 相结合。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容