基于docker的QGIS软件应用指南(二)

3,064 阅读7分钟

写在前面:感谢长三角智慧农业研究院史云院长对此项工作的支持!

本篇文章的目的是:以一个模版制图的功能为例,深入分析qgis工程化二次开发框架,记录在功能完成过程中的关键步骤,便于复盘与流程优化。

在国土调查,林业,农业,矿业等领域,都会有很强的定制化制图需求,比如标准的图件成果需要有指北针,比例尺,和丰富的地理对象的样式,这样在Web应用的框架下从0到一实现此功能是难度极大的事情,工程的复杂性和稳定性对于任何工程师来说都是极大的考验,但是开源世界的特性意味着QGIS这种功能性和工程性均极佳的开源软件代码的架构,组织和调试都是开放给各个用户的,相当于是一个优秀的案例,可以分析其功能架构并借用其已有能力。因此在此部分我们将从头讲述如何通过qgis的制图能力,完成一个web制图应用的核心功能。

技术路线

设计思路

QGIS对地理信息数据的组织方式基本上遵循如下分层,数据源这一层通过不同的类,用来屏蔽各种数据存储格式和数据类型的差异,无论是shpfile,还是数据库表,还是tif影像,通过相应的数据源处理类之后,都会变成Layer图层概念,图层加上样式就会呈现出我们看到的结果:蓝色的河流,网状的村庄,灰黑色的DEM数据等;其中一个数据源对应一个图层(一个图层只有一个数据源),一个图层可以有多种样式备选,但最终只能展现出一种样式,样式的信息最后都通过一个.qml文件格式的数据存储下来,最后以上这些东西组成了一个LayoutItemMap,这个LayoutItemMap加上其他的组件如LayoutItemLegend,LayoutItemTable,LayoutItemScale,LayoutItemCompass等等,组合成了制图成果打印所需要的Layout

实现路线

制图输出的功能的实现路线是典型的将大象关入冰箱的方式如图所示:

  1. 搭建开发环境,参考本系列上篇文章
  2. 遵循标准的开发流程,进行核心功能的微服务开发:
    1. 准备开发调试数据:
      1. 数据来源于QGIS官方仓库示例数据:codeload.github.com/qgis/QGIS-T…
      2. OpenStreetMap 街道与城市数据 (www.openstreetmap.org/)
      3. 南非的城市国家测绘数据 (ngi.dalrrd.gov.za/)
      4. 遥感高程数据 (srtm.csi.cgiar.org/)
    1. 编写核心制图程序
      1. 加载shpfile图层
      2. 加载tif图层
      3. 应用样式
      4. 加载布局
      5. 填充图层
      6. 生成文件
    1. 封装为Restful接口
      1. 将上一步的核心制图程序利用flask+ gunicorn+gevent封装成服务API供外部调用
    1. 功能调试
      1. 利用apifox等调试工具,进行api调试
    1. 编写dockerfiledocker-compose文件启动脚本
      1. dockerfile用于打包Image镜像,docker-compose用于进行部署时的一键启动
  1. 部署至生产环境

其余工具化工作

这个制图分析的微服务对于调用者来说目前还是半成品,若想要完成工具的最终形态,还需要进行其余的工具化工作,包括构建一个应用界面让用户能够以惯常的web应用通过鼠标与键盘进行操作,设计该系统与其余系统的数据交换逻辑等,这部分工作的细节同样很多,但并不是此系列的侧重点,放几张设计草图在此,其余内容就不在此处介绍了。

操作细节

如何加载不同的数据源及其对应的样式文件?

在这里的设计中我们将制图过程封装成为一个函数,函数接收一系列参数:采用一个对象数组来存储图层及对应的样式文件,采用四个变量分别来存储制图模板文件,模板中地图组件的ID,输出成果pdf文件和图像文件。

def generateMapLayer(layerFiles, templateFile, templateMap, outputPDF, outputPNG=""):
    try:
        # 加载图层
        project = QgsProject.instance()
        printlayers = []
        for each in layerFiles:
            if (each["fileType"] == "shp"): curLayer = QgsVectorLayer(each["location"], each["layerName"], 'ogr') 
            if (each["fileType"] == "tif"): curLayer = QgsRasterLayer(each["location"], each["layerName"])
            if not curLayer.isValid():
                print(each["location"], 'fail to load!')
            else:
                project.addMapLayer(curLayer)
                curLayer.loadNamedStyle(each["styleLocation"])
                curLayer.triggerRepaint()
                printlayers.append(curLayer)
                # 加载布局
        layout = QgsPrintLayout(project)
        layout.initializeDefaults()
        layout.setName("myLayout") # 必须要设置 layout 名称,不然会有 PrintError 产生
        doc = QDomDocument()
        with open(templateFile) as f:
            doc.setContent(f.read())
        layout.loadFromTemplate(doc, QgsReadWriteContext(), False)
        # layout.setPageSize('A4', QgsLayoutItemPage.Orientation.Portrait)
        map = layout.itemById(templateMap)
        map.zoomToExtent(printlayers[0].extent())

        # 生成文件
        exporter = QgsLayoutExporter(layout)
        if (outputPDF): exporter.exportToPdf(outputPDF, QgsLayoutExporter.PdfExportSettings())
        if (outputPNG): exporter.exportToImage(outputPNG, QgsLayoutExporter.ImageExportSettings())
    except Exception as e:
        print(repr(e))
    return

if __name__ == "__main__":
    
    layerFiles = [{
        "systemName":"someFuckinglayer1",
        "layerName":"SRTM layer",
        "fileType":"tif",
        "location":"../exercise_data/raster/SRTM/srtm_41_19.tif",
        "styleLocation":"../exercise_data/shapefile/custom-raster.qml"
    },{
        "systemName":"someFuckinglayer0",
        "layerName":"places",
        "fileType":"shp",
        "location":"../exercise_data/shapefile/custom.shp",
        "styleLocation":"../exercise_data/shapefile/custom-shp.qml"
    }]
    templateFile = "../exercise_data/shapefile/template.qpt"
    templateMap = "Map 1"
    outputPDF = "output.pdf"
    outputPNG = "output.png"
    generateMapLayer(layerFiles, templateFile, templateMap, outputPDF)

如何进行服务化封装?

采用一个轻量级的 web 应用框架 flask,使用flask提供的装饰器和一系列包装函数,完成http接口的封装,用于接收数据并返回结果。

app = flask.Flask(__name__)
@app.route("/heart")
def heart():
    return "living!"

@app.route("/generateMap", methods=['GET', 'POST'])
def generate():
    try:
        if flask.request.method == 'GET':
            return "you must use POST method and pass specific Data in this API"
        else:
            req = json.loads(flask.request.data)
            if not req: return 'fail'
            layerFiles = req["layerFiles"]
            templateFile = req["templateFile"]
            templateMap = req["templateMap"]
            outputPDF = req["outputPDF"]
            outputPNG = req["outputPNG"]
            gml.generateMapLayer(layerFiles, templateFile, templateMap, outputPDF, outputPNG)
            return flask.make_response("OK", 200)
    except Exception as e:
        print(repr(e))
        return flask.make_response(flask.jsonify({'error': repr(e)}), 500)
@app.errorhandler(404)
def not_found():
    return flask.make_response(flask.jsonify({'error': 'fail'}), 404)

如何部署应用程序?

需要借助gunicorn``gevent进行生产环境的flask应用部署,其中gunicorn是一个标准的server应用容器,用于启动服务,接收http接口调用,gevent是一个在libuv之上,基于协程处理异步网络请求的库,两者配合就能够部署生产级的flask应用。

workers = 5    # 定义同时开启的处理请求的进程数量,根据网站流量适当调整
worker_class = "gevent"   # 采用 gevent 库,支持异步处理请求,能够有效提高吞吐量
bind = "0.0.0.0:9909"

编写启动脚本用于构建镜像时的启动。

gunicorn generate_map_restful:app -c gunicorn.conf

最后通过dockerfiledocker-compose处理脚本的编写打包成镜像,并可以在服务部署过程中一键启动

结语

在国内ArcGIS软件控制力的影响下,QGIS的二次开发经验少之又少,但是在全球范围内,其使用场景和受众范围与ArcGIS相当,其功能又不输于ArcGIS。如果能成为QGIS的使用和二次开发的高手,能够有效地帮助我们解决日后面临的遥感数据处理的棘手问题,而且遵循QGIS的开源协议也可以避免未来公司上市时的商业纠纷,这也会真正地让“遥感数据自动化批量处理任务”的轻量化成为可能(这意味着以后不只有专业公司才有遥感数据批量处理的能力,其他公司投入较少的人力成本也能够基于QGIS二次开发出来的软件集群进行卫星和遥感数据的处理),以目前的调研结果来看,关于QGIS软件二次开发的领域是一片蓝海,其英文的开发文档详实,社区活跃,更新频率很高,是一个值得深入研究并内化的开源项目,该项目由南非政府资助,是一份开源宝藏!

参考文献

  1. 容器打包时添加脚本命令以此解决gui容器应用的问题:askubuntu.com/questions/1…
  2. flask应用容器化参考: blog.csdn.net/weixin_3023…
  3. qgis打印制图参考: gis.stackexchange.com/questions/3…
  4. qgis打印制图参考: gis.stackexchange.com/questions/2…
  5. qgis打印制图参考:python.hotexamples.com/examples/qg…

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情