第13章 Kotlin 集成 SpringBoot 服务端开发(

作者: 光剑书架上的书 | 来源:发表于2017-10-26 23:03 被阅读74次

    13.2.10 搜索关键字管理

    本节我们开发爬虫爬取的关键字管理的功能。

    数据库实体类

    首先,新建实体类SearchKeyWord 如下

    package com.easy.kotlin.picturecrawler.entity
    
    import 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.picturecrawler
    
    import com.easy.kotlin.picturecrawler.dao.SearchKeyWordRepository
    import com.easy.kotlin.picturecrawler.entity.SearchKeyWord
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.boot.CommandLineRunner
    import org.springframework.boot.SpringApplication
    import org.springframework.boot.autoconfigure.SpringBootApplication
    import org.springframework.core.Ordered
    import org.springframework.core.annotation.Order
    import org.springframework.scheduling.annotation.EnableScheduling
    import org.springframework.stereotype.Component
    import java.io.File
    
    
    @SpringBootApplication
    @EnableScheduling
    class PictureCrawlerApplication
    
    fun 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))
    @ResponseBody
    fun 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.job
    
    import com.easy.kotlin.picturecrawler.dao.SearchKeyWordRepository
    import kotlinx.coroutines.experimental.CommonPool
    import kotlinx.coroutines.experimental.launch
    import kotlinx.coroutines.experimental.runBlocking
    import org.springframework.beans.factory.annotation.Autowired
    import org.springframework.scheduling.annotation.Scheduled
    import org.springframework.stereotype.Component
    import java.util.*
    
    @Component
    class 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'
    

    然后把我们的定时任务代码改写为

    
    @Component
    class 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.entity
    
    import 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 = 1
    image.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 服务端领域也必将受到越来越多的程序员的关注。

    相关文章

      网友评论

        本文标题:第13章 Kotlin 集成 SpringBoot 服务端开发(

        本文链接:https://www.haomeiwen.com/subject/rrllpxtx.html