13.2.10 搜索关键字管理
本节我们开发爬虫爬取的关键字管理的功能。
数据库实体类
首先,新建实体类SearchKeyWord 如下
package com.easy.kotlin.picturecrawler.entityimport java.util.*import javax.persistence.*@Entity@Table(indexes = arrayOf(Index(name = "idx_key_word", columnList = "keyWord", unique = true)))class SearchKeyWord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long = -1 @Column(name = "keyWord", length = 50, nullable = false, unique = true) var keyWord: String = "" @Column(nullable = true) var totalImage: Int? = 0 var gmtCreated: Date = Date() var gmtModified: Date = Date() var isDeleted: Int = 0 //1 Yes 0 No var deletedDate: Date = Date() }
其中,keyWord 是搜索关键字,有唯一性约束,同时我们给它建立了索引。
dao 层接口
我们来实现插入数据的 dao 层接口
@Modifying @Transactional @Query(value = "INSERT INTO `search_key_word` (`deleted_date`, `gmt_created`, `gmt_modified`, `is_deleted`, `key_word`) VALUES (now(), now(), now(), '0', :keyWord) ON DUPLICATE KEY UPDATE `gmt_modified` = now()", nativeQuery = true) fun saveOnNoDuplicateKey(@Param("keyWord") keyWord: String): Int
其中,ON DUPLICATE KEY UPDATE 这句表明当遇到重复的键值的时候,执行更新 gmt_modified = now() 的操作。这里nativeQuery = true ,表示使用的是原生 SQL 查询。
系统启动初始化动作
我们在应用启动类PictureCrawlerApplication 中添加初始化动作
package com.easy.kotlin.picturecrawlerimport com.easy.kotlin.picturecrawler.dao.SearchKeyWordRepositoryimport com.easy.kotlin.picturecrawler.entity.SearchKeyWordimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.CommandLineRunnerimport org.springframework.boot.SpringApplicationimport org.springframework.boot.autoconfigure.SpringBootApplicationimport org.springframework.core.Orderedimport org.springframework.core.annotation.Orderimport org.springframework.scheduling.annotation.EnableSchedulingimport org.springframework.stereotype.Componentimport java.io.File@SpringBootApplication@EnableSchedulingclass PictureCrawlerApplicationfun main(args: Array<String>) { SpringApplication.run(PictureCrawlerApplication::class.java, *args)}@Component@Order(value = Ordered.LOWEST_PRECEDENCE)class initSearchKeyWordRunner : CommandLineRunner { @Autowired lateinit var searchKeyWordRepository: SearchKeyWordRepository override fun run(vararg args: String) { var keyWords = File("搜索关键词列表.data").readLines() keyWords.forEach { val SearchKeyWord = SearchKeyWord() SearchKeyWord.keyWord = it searchKeyWordRepository.saveOnNoDuplicateKey(it) } } }
Spring Boot应用程序在启动后会去遍历 CommandLineRunner 接口的实例并运行它们的run方法。使用@Order注解来指定 CommandLineRunner 实例的运行顺序。
搜索查询接口
查询所有关键字记录接口如下
@Query("SELECT a from #{#entityName} a where a.isDeleted=0 order by a.id desc")override fun findAll(pageable: Pageable): Page<SearchKeyWord>
模糊搜索关键字接口如下
@Query("SELECT a from #{#entityName} a where a.isDeleted=0 and a.keyWord like %:searchText% order by a.id desc")fun search(@Param("searchText") searchText: String, pageable: Pageable): Page<SearchKeyWord>
模糊搜索 http 接口实现
跟搜索图片分类的逻辑类似,模糊搜索关键字的接口如下
@RequestMapping(value = "searchKeyWordJson", method = arrayOf(RequestMethod.GET)) @ResponseBody fun sotuSearchJson(@RequestParam(value = "page", defaultValue = "0") page: Int, @RequestParam(value = "size", defaultValue = "10") size: Int, @RequestParam(value = "searchText", defaultValue = "") searchText: String): Page<SearchKeyWord> { return getPageResult(page, size, searchText) } private fun getPageResult(page: Int, size: Int, searchText: String): Page<SearchKeyWord> { val sort = Sort(Sort.Direction.DESC, "id") // 注意:PageRequest.of(page,size,sort) page 默认是从0开始 val pageable = PageRequest.of(page, size, sort) if (searchText == "") { return searchKeyWordRepository.findAll(pageable) } else { return searchKeyWordRepository.search(searchText, pageable) } }
前端列表页面代码
search_keyword_view.ftl 模板页面代码如下
<#include 'common/head.ftl'><#include 'common/nav.ftl'><form id="add_key_word_form"> <div class="col-lg-3"> <div class="input-group"> <input name="keyWord" id="add_key_word_form_keyWord" type="text" class="form-control" placeholder="输入爬虫抓取关键字"> <span class="input-group-btn"> <button id="add_key_word_form_save_button" class="btn btn-default" type="button"> 保存 </button> </span> </div><!-- /input-group --> </div><!-- /.col-lg-3 --></form><table id="search_keyword_table"></table><#include 'common/foot.ftl'><script src="search_keyword_table.js"></script>
search_keyword_table.js 代码如下
$(function () { $.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['zh-CN']) var searchText = $('.search').find('input').val() var columns = [] columns.push( { title: 'ID', field: 'id', align: 'center', valign: 'middle', width: '10%', formatter: function (value, row, index) { return value } }, { title: '关键字', field: 'keyWord', align: 'center', valign: 'middle', formatter: function (value, row, index) { var html = "<a href='sotu_view?keyWord=" + value + "' target='_blank'>" + value + "</a>" return html } }, { title: '图片总数', field: 'totalImage', align: 'center', valign: 'middle', formatter: function (value, row, index) { var html = "<a href='sotu_view?keyWord=" + row.keyWord + "' target='_blank'>" + row.totalImage + "</a>" return html } }) $('#search_keyword_table').bootstrapTable({ url: 'searchKeyWordJson', sidePagination: "server", queryParamsType: 'page,size', contentType: "application/x-www-form-urlencoded", method: 'get', striped: false, //是否显示行间隔色 cache: false, //是否使用缓存,默认为true,所以一般情况下需要设置一下这个属性(*) pagination: true, //是否显示分页(*) paginationLoop: true, paginationHAlign: 'right', //right, left paginationVAlign: 'bottom', //bottom, top, both paginationDetailHAlign: 'left', //right, left paginationPreText: ' 上一页', paginationNextText: '下一页', search: true, searchText: searchText, searchTimeOut: 500, searchAlign: 'right', searchOnEnterKey: false, trimOnSearch: true, sortable: true, //是否启用排序 sortOrder: "desc", //排序方式 sortName: "id", pageNumber: 1, //初始化加载第一页,默认第一页 pageSize: 10, //每页的记录行数(*) pageList: [8, 16, 32, 64, 128], // 可选的每页数据 totalField: 'totalElements', // 所有记录 count dataField: 'content', //后端 json 对应的表格List数据的 key columns: columns, queryParams: function (params) { return { size: params.pageSize, page: params.pageNumber - 1, sortName: params.sortName, sortOrder: params.sortOrder, searchText: params.searchText } }, classes: 'table table-responsive full-width', }) $(document).on('keydown', function (event) { // 键盘翻页事件 var e = event || window.event || arguments.callee.caller.arguments[0]; if (e && e.keyCode == 38 || e && e.keyCode == 37) {//上,左 // 上一页 $('.page-pre').click() } if (e && e.keyCode == 40 || e && e.keyCode == 39) {//下,右 // 下一页 $('.page-next').click() } }) $('#add_key_word_form_save_button').on('click', function () { var keyWord = $('#add_key_word_form_keyWord').val() $.ajax({ url: 'save_keyword', type: 'get', data: {keyWord: keyWord}, success: function (response) { if (response == "1") { alert("保存成功") } else { alert("保存失败") } }, error: function (error) { alert(JSON.stringify(error)) } }) }) })
添加爬取关键字
添加爬取关键字 http 接口代码如下
@RequestMapping(value = "save_keyword", method = arrayOf(RequestMethod.GET,RequestMethod.POST))@ResponseBodyfun save(@RequestParam(value = "keyWord")keyWord:String): String { if(keyWord==""){ return "0" }else{ searchKeyWordRepository.saveOnNoDuplicateKey(keyWord) return "1" } }
前端输入框表单代码
<form id="add_key_word_form"> <div class="col-lg-3"> <div class="input-group"> <input name="keyWord" id="add_key_word_form_keyWord" type="text" class="form-control" placeholder="输入爬虫抓取关键字"> <span class="input-group-btn"> <button id="add_key_word_form_save_button" class="btn btn-default" type="button"> 保存 </button> </span> </div><!-- /input-group --> </div><!-- /.col-lg-3 --></form>
对应的 js 代码如下
$('#add_key_word_form_save_button').on('click', function () { var keyWord = $('#add_key_word_form_keyWord').val() $.ajax({ url: 'save_keyword', type: 'get', data: {keyWord: keyWord}, success: function (response) { if (response == "1") { alert("保存成功") $('#search_keyword_table').bootstrapTable('refresh') } else { alert("数据不能为空") } }, error: function (error) { alert(JSON.stringify(error)) } }) })
其中, $('#search_keyword_table').bootstrapTable('refresh') 是当保存成功后,刷新表格内容。
定时更新该关键字的图片总数任务
最终的效果如下
爬取关键字管理页面
模糊搜索“秋”
更新 search_key_word 表 total_image 字段的 SQL 逻辑如下
@Modifying@Transactional@Query("update search_key_word a set a.total_image = (select count(*) from image i where i.is_deleted=0 and i.category like concat('%',a.key_word,'%'))", nativeQuery = true)fun batchUpdateTotalImage()
表示该对应关键字包含的图片总数。
然后,我们用一个定时任务去执行它
package com.easy.kotlin.picturecrawler.jobimport com.easy.kotlin.picturecrawler.dao.SearchKeyWordRepositoryimport kotlinx.coroutines.experimental.CommonPoolimport kotlinx.coroutines.experimental.launchimport kotlinx.coroutines.experimental.runBlockingimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.scheduling.annotation.Scheduledimport org.springframework.stereotype.Componentimport java.util.*@Componentclass BatchUpdateJob { @Autowired lateinit var searchKeyWordRepository: SearchKeyWordRepository @Scheduled(cron = "0 */5 * * * ?") fun job() { println("开始执行定时任务 batchUpdateTotalImage: ${Date()}") searchKeyWordRepository.batchUpdateTotalImage() } }
13.2.11 使用协程实现异步爬虫任务
上面我们的定时任务都是同步的。当我们想用 http 接口去触发任务执行的时候,可能并不想一直等待,这个时候可以使用异步的方式。这里我们使用 Kotlin 提供的轻量级线程——协程来实现。在常用的并发模型中,多进程、多线程、分布式是最普遍的,不过近些年来逐渐有一些语言以first-class或者library的形式提供对基于协程的并发模型的支持。其中比较典型的有Scheme、Lua、Python、Perl、Go等以first-class的方式提供对协程的支持。同样地,Kotlin也支持协程。(关于协程的更多介绍,可参考《Kotlin 极简教程》第9章 轻量级线程:协程 )
我们在 build.gradle 中添加kotlinx-coroutines-core 依赖
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '0.19.2'
然后把我们的定时任务代码改写为
@Componentclass BatchUpdateJob { @Autowired lateinit var searchKeyWordRepository: SearchKeyWordRepository @Scheduled(cron = "0 */5 * * * ?") fun job() { doBatchUpdate() } fun doBatchUpdate() = runBlocking { launch(CommonPool) { println("开始执行定时任务 batchUpdateTotalImage: ${Date()}") searchKeyWordRepository.batchUpdateTotalImage() } } }
同样的爬虫抓取图片的任务也可以改写成
fun doCrawJob() = runBlocking { val list = searchKeyWordRepository.findAll() for (i in 1..1000) { list.forEach { launch(CommonPool) { saveImage(it.keyWord, i) } } } }
其中,launch函数会以非阻塞(non-blocking)当前线程的方式,启动一个新的协程后台任务,并返回一个Job类型的对象作为当前协程的引用。我们把真正要执行的代码逻辑放到 launch(CommonPool) { } 中。这样我们就可以手动启动任务异步执行了。
13.2.12 图片存入数据库并在前端展现
数据库实体类:
package com.easy.kotlin.picturecrawler.entityimport java.util.*import javax.persistence.*@Entity@Table(indexes = arrayOf( Index(name = "idx_url", unique = true, columnList = "url"), Index(name = "idx_category", unique = false, columnList = "category")))class Image { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long = -1 @Version var version: Int = 0 @Column(length = 255, unique = true, nullable = false) var category: String = "" var isFavorite: Int = 0 @Column(length = 255, unique = true, nullable = false) var url: String = "" var gmtCreated: Date = Date() var gmtModified: Date = Date() var isDeleted: Int = 0 //1 Yes 0 No var deletedDate: Date = Date() @Lob var imageBlob: ByteArray = byteArrayOf() /* 0-Baidu 1-Gank */ var sourceType: Int = 0 override fun toString(): String { return "Image(id=$id, version=$version, category='$category', isFavorite=$isFavorite, url='$url', gmtCreated=$gmtCreated, gmtModified=$gmtModified, isDeleted=$isDeleted, deletedDate=$deletedDate)" } }
其中 @Lob var imageBlob: ByteArray = byteArrayOf() 这个字段存储图片的 Base64内容。
图片比特流数组存入数据库代码
val image = Image() image.category = "干货集中营福利"image.url = url image.sourceType = 1image.imageBlob = getByteArray(url) logger.info("Image = ${Image}") imageRepository.save(Image)
其中的getByteArray(url) 函数实现代码如下
private fun getByteArray(url: String): ByteArray { val urlObj = URL(url) return urlObj.readBytes() }
前端 html 展示图片代码:
{ title: '图片', field: 'imageBlob', align: 'center', valign: 'middle', formatter: function (value, row, index) { // var html = "<img onclick=downloadImage('" + value + "') width='100%' src='" + value + "'>" var html = '<img onclick="downBase64Image(this.src)" width="100%" src="data:image/jpg;base64,' + value + '"/>' return html } }
点击下载 js :
function downloadImage(src) { var $a = $("<a></a>").attr("href", src).attr("download", "sotu.png"); $a[0].click(); }function downBase64Image(url) { var blob = base64Img2Blob(url); url = window.URL.createObjectURL(blob); var $a = $("<a></a>").attr("href", url).attr("download", "sotu.png"); $a[0].click(); }function base64Img2Blob(code) { var parts = code.split(';base64,'); var contentType = parts[0].split(':')[1]; var raw = window.atob(parts[1]); var rawLength = raw.length; var uInt8Array = new Uint8Array(rawLength); for (var i = 0; i < rawLength; ++i) { uInt8Array[i] = raw.charCodeAt(i); } return new Blob([uInt8Array], {type: contentType}); }
13.2.13 IDEA 的数据库客户端工具
IDEA 的数据库客户端工具
本节完整的项目源码:https://github.com/EasySpringBoot/picture-crawler
本章小结
在Spring Framework 5.0中已经添加了对 Kotlin 的支持。使用 Kotlin 集成 SpringBoot 开发非常流畅自然,几乎不需要任何迁移成本。所以,Kotlin 在未来的 Java 服务端领域也必将受到越来越多的程序员的关注。
Kotlin 开发者社区
国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。
作者:一个会写诗的程序员
链接:https://www.jianshu.com/p/9a6ea6201baa