1.mbtiles简介

mapbox Docs : MBTiles

MbTiles 是一种用于在 sqllite 数据库中存储任意瓦片地图数据用于即时使用和高效传输的规范。

MBTiles瓦片存储规范的制定主要是为了解决、优化传统瓦片的存储方案存在的两个问题:

  1. 可移植性差,无法在移动端上做离线应用
  2. 存储量大,大家都知道,因为互联网上的地图都以“瓦片”的形式存在,高层级的瓦片存储数量往往是海量的。例如,对于“Web 墨卡托”投影的瓦片金字塔来说,第15层数据有 4^15 = 1073741824个瓦片。

参见文档,Mbtiles其实本质是一个SQLite3文件,大家知道,SQLite有它天然的可移植特性(整个数据库就是一个sqlite3文件,当然可移植性够好)。这个解决了1的问题。

下面简单解读一下规范,该规范描述了这个sqlite3文件的表必须符合以下规定:

  • 必须要一个名叫“metadata”的table(表)或者view(视图),这个表其实就是“元数据”表,用来描述存储的数据。这个表必须要有两列,一列是"name",一列是“value”,这两列都是text类型的。这个表必须包含一些特定的row,例如name=“name”,value=“数据集名称”;name: “format” ,value: “pbf"代表存储的瓦片格式;name: “center” ,value: -122.1906,37.7599,1代表这个数据集存储的数据中心在这个经纬度处。对于Mapbox矢量瓦片集,有特殊的json字段,用来描述矢量瓦片集。

  • 必须要有一个名字叫“tiles”的表。建表语句

    CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob);

    它可能会有一个索引:

    CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row);

    这个表主要存了x/y/z和对应的瓦片数据(BLOB)

2.金字塔模型

要了解mbtiles是怎么存储的,首先需要先了解瓦片地图的金字塔模型

众所周知,对于Web而言,将矢量图层渲染为栅格数据是一个昂贵的计算过程。对于不经常修改的矢量图层,重复描绘同样线条会极大浪费CPU资源。继Google Maps推出瓦片地图后,各大地图网站都转而采取预先渲染标注好的海量图片并分割为256*256像素瓦片的策略,从而使得浏览器能快速地缓存小尺寸且不会更名的瓦片。

瓦片地图是一个三维的概念,即金字塔模型,其每增大一级,会在上一级瓦片的基础上一分为四,随着分辨率的提升,显示的内容也渐显丰富。通常使用xyz三维坐标系来对一张瓦片进行精准定位,其中z用于表示地图的层级,xy表示某个层级内的瓦片平面。该瓦片平面可被视为数学上常见的笛卡尔坐标系,只要确定了横轴坐标x和纵轴坐标y,便可以唯一确定在这个瓦片平面上的每一个瓦片,如图所示。

瓦片地图结构

3.mbtiles结构

示例数据2017-07-03_planet_z0_z14.mbtiles,下载地址:https://data.maptiler.com/downloads/tileset/osm/

mbtiles中几个比较重要的表:

  • map:存储层级以及行列号**(金字塔模型**),以及瓦片id
  • images:存储瓦片id以及对应的图片数据
  • metadata:存取地图的元数据信息

3.1 metadata表

image-20230616213420481

3.2 tiles视图

视图构建SQL:

1
2
3
4
5
6
7
SELECT
        map.zoom_level AS zoom_level,
        map.tile_column AS tile_column,
        map.tile_row AS tile_row,
        images.tile_data AS tile_data
    FROM map
    JOIN images ON images.tile_id = map.tile_id

通过sql语句我们可以知道tiles视图其实就是把map表里面的瓦片层级信息与images表的瓦片图片数据关联起来

视图基本结构:

image-20230616213657226

4.Java加载Mbtiles发布地图服务

示例数据2017-07-03_planet_z0_z14.mbtiles,下载地址:https://data.maptiler.com/downloads/tileset/osm/

4.1 加载mbtiles

加载sqlite驱动

1
2
3
4
5
6
<!-- sqlite驱动 -->
<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.34.0</version>
</dependency>

连接数据库

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
try {
    Class.forName("org.sqlite.JDBC");
}
catch (ClassNotFoundException e) {
    // e.printStackTrace();
    log.warn("Database driver not found!");
}
// 得到连接 会在你所填写的文件夹建一个你命名的文件数据库
Connection conn;
// String conurl = "jdbc:sqlite:E:/mapArchiveFiles/tianditu/img_c.mbtiles";
conn = DriverManager.getConnection(conurl,null,null);

封装为bean

地图服务的接口每次都需请求上百张图片,如果每次请求都重新连接数据库会导致程序崩溃,所以需将其封装为bean,暴露出连接mbtiles的Connection,这样只需项目启动时连接一次数据库即可

注意:连接数据库 返回连接数据库的Connection 不能返回执行SQL语句的statement,因为每个Statement对象只能同时打开一个ResultSet对象,高并发情况下会出现 rs.isOpen() on exec 的错误

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Slf4j
@Configuration
public class SqliteConfig {
    
    @Bean(name = "mbtilesConnection")
    Connection mbtilesConnection() throws SQLException {
        String path = "Z:/2017-07-03_planet_z0_z14.mbtiles"; // mbtiles路径
        return getConnection("jdbc:sqlite:" + path); 
    }
    
    public static Connection getConnection(String conurl) throws SQLException {
        try {
            Class.forName("org.sqlite.JDBC");
        }
        catch (ClassNotFoundException e) {
            // e.printStackTrace();
            log.warn("Database driver not found!");
        }
        // 得到连接 会在你所填写的文件夹建一个你命名的文件数据库
        Connection conn;
        // String conurl = "jdbc:sqlite:E:/mapArchiveFiles/tianditu/img_c.mbtiles";
        conn = DriverManager.getConnection(conurl,null,null);
        // 设置自己主动提交为false
        conn.setAutoCommit(false);

        //推断表是否存在
        ResultSet rsTables = conn.getMetaData().getTables(null, null, "tiles", null);
        if(!rsTables.next()){
            log.warn("{} does not exist!", conurl);
        } else {
            log.info("{} successfully connected!", conurl);
        }

        return conn;

        // return conn.createStatement();
    }
}

4.2 查询mbtiles

根据请求的层级z以及行列号x、y到数据库的tiles表查找对应的瓦片数据

**注意:**由于mapbox只能加载未压缩的pbf格式数据,直使用tippecanoe生成的pbf是经过gzip压缩的数据,不执行解压缩,mapbox加载数据会报:“Unimplemented type: 3” 错误,所以必须对得到的tile_data解压缩:FileUtils.gzipUncompress(imgByte)

是否需要解压缩要根据生成mbtiles时对瓦片采取的操作而定,例如使用mbutil工具实现地图切片向mbtiles文件格式的转换,其生成mbtiles时图片并没有经过压缩,所以在获取到tile_data时就不需要解压缩,如下是mbutil生成mbtiles时的部分代码片段,可以看到mbutil只是对图片二进制处理,并没有压缩

image-20230616221228779
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private void queryMbtilesWithUncompress(
    TilesDTO tilesDTO, Connection connection, HttpServletResponse response){
    try {
        Statement statement = connection.createStatement();
        // 得到结果集
        String sql = "SELECT * FROM tiles WHERE zoom_level = "+ tilesDTO.getZoom_level() +
            " AND tile_column = "+ tilesDTO.getTile_column() +
            " AND tile_row = "+ tilesDTO.getTile_row() ;
        ResultSet rs = statement.executeQuery(sql);
        if(rs.next()) {
            byte[] imgByte = (byte[]) rs.getObject("tile_data");
            // 解压缩
            byte[] bytes = FileUtils.gzipUncompress(imgByte);
            InputStream is = new ByteArrayInputStream(bytes);
            OutputStream os = response.getOutputStream();
            try {
                int count = 0;
                byte[] buffer = new byte[1024 * 1024];
                while ((count = is.read(buffer)) != -1) {
                    os.write(buffer, 0, count);
                }
                os.flush();
            } catch (IOException e) {
                // e.printStackTrace();
            } finally {
                os.close();
                is.close();
            }
        }
        else{
            log.debug("sql: {}",sql);
            log.debug("未找到瓦片!");
        }
        rs.close();
        //statement在每次执行之后都要关了
        statement.close();
    }catch (Exception e){
        // e.printStackTrace();
    }
}

GZIP解压

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
//GZIP解压
public static byte[] gzipUncompress(byte[] bytes) {
    if (bytes == null || bytes.length == 0) {
        return null;
    }
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    ByteArrayInputStream in = new ByteArrayInputStream(bytes);
    try {
        GZIPInputStream ungzip = new GZIPInputStream(in);
        byte[] buffer = new byte[256];
        int n;
        while ((n = ungzip.read(buffer)) >= 0) {
            out.write(buffer, 0, n);
        }
    } catch (IOException e) {
        log.error("gzip uncompress error.", e);
    }

    return out.toByteArray();
}

4.3 接口编写

controller层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@ApiOperation(value = "得到mapbox瓦片" )
@GetMapping("/mapbox/{z}/{x}/{y}.pbf")
public void getMapboxTiles(
    @ApiParam(name = "z", value = "zoom_level") @PathVariable int z,
    @ApiParam(name = "x", value = "tile_column") @PathVariable int x,
    @ApiParam(name = "y", value = "tile_row") @PathVariable int y ,
    HttpServletResponse response){


    TilesDTO tilesDTO = new TilesDTO();
    tilesDTO.setTile_column(x);
    tilesDTO.setTile_row((int)(Math.pow(2,z)-1-y));
    tilesDTO.setZoom_level(z);

    tilesService.getMapboxTiles(tilesDTO, response);

}

5.mapbox加载地图服务

5.1 html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>Add a vector tile source</title>
    <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
    <link href="https://api.mapbox.com/mapbox-gl-js/v2.8.2/mapbox-gl.css" rel="stylesheet">
    <script src="https://api.mapbox.com/mapbox-gl-js/v2.8.2/mapbox-gl.js"></script>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        #map {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script>
        mapboxgl.accessToken = 'pk.eyJ...e2_rdU2nOUvtwltBIZtZg';
        const map = new mapboxgl.Map({
            container: 'map',
            style: 'mapbox://styles/mapbox/light-v10',
            zoom: 5,
            center: [118.447303, 30.753574]
        });

        map.on("load", () => {
            map.addSource("testMapLine", {
                type: "vector",
         		tiles: ["http://localhost:9000/tiles/mapbox/{z}/{x}/{y}.pbf
            });

            map.addLayer({
                id: "testMapLineLayer",
                type: "fill",
                source: "testMapLine",
                // ST_AsMVT() uses 'default' as layer name
                "source-layer": "water",
				"filter": ["all", ["!=", "brunnel", "tunnel"]],
                minzoom: 0,
                maxzoom: 22,
				  "paint": {
					"fill-color": "rgb(158,189,255)",
					"fill-opacity": ["literal", 1]
				  }
            });
        })

    </script>

</body>

</html>

5.2 效果展示

可以看到水体被加载出来了

image-20230616225226075

6.发布osm-liberty形式的地图服务

mapbox还有一种直接请求osm-liberty.json的方式加载地图服务

6.1 地图服务接口

接口层

1
2
3
4
5
@ApiOperation(value = "得到mapbox元数据json" )
@GetMapping("/mapbox/liberty.json")
public JSONObject getMapboxLibertyJson(){
    return tilesService.getMapboxLibertyJson();
}

Service层

首先加载原始的osm_liberty.json,将自定义tiles.json接口地址更新至osm_liberty.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public JSONObject getMapboxLibertyJson() {

    try {
        File file = ResourceUtils.getFile(resourcePath + "/osm_liberty.json");

        Map map = FileUtils.readJson(file);
        JSONObject jsonObject = new JSONObject(map);

        String sourceUrl = "http://localhost:9000/tiles/mapbox/metadata/tiles.json";

        ((Map)((Map) jsonObject.get("sources")).get("openmaptiles")).put("url", sourceUrl);

        return jsonObject;

    }catch (Exception e){
        e.printStackTrace();
    }

    return null;

}

tiles.json接口

1
2
3
4
5
6
7
8
// "https://api.maptiler.com/tiles/v3/tiles.json?key=XAapkmkXQpx839NCfnxD"
@ApiOperation(value = "得到mapbox元数据json" )
@GetMapping("/mapbox/metadata/tiles.json")
public JSONObject getMapboxTilesMetadataJson(){

    return tilesService.getMapboxTilesMetadataJson();

}

getMapboxTilesMetadataJson方法中,可以看到我们用到了mbtiles中metadata表里面的数据,同时,tiles.jsontiles就是我们[4.3](# 4.3 接口编写)编写的接口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public JSONObject getMapboxTilesMetadataJson() {

    JSONObject result = new JSONObject();
    
    try {
        Statement statement = mapboxConnection.createStatement();
        // 得到结果集
        String sql = "SELECT * FROM metadata";
        ResultSet rs = statement.executeQuery(sql);
        while (rs.next()) {
            String name = (String) rs.getObject("name");
            String value = (String) rs.getObject("value");

            JSONObject jsonObject = formatMetadata(name, value);

            result.put(jsonObject.getString("name"),jsonObject.get("value"));

        }
        rs.close();
        //statement在每次执行之后都要关了
        statement.close();
    }catch (Exception e){
        e.printStackTrace();
    }

    result.put("tiles", Arrays.asList("http://localhost:9000/tiles/mapbox/{z}/{x}/{y}.pbf"));
    // result.put("tiles", Arrays.asList("https://api.maptiler.com/tiles/v3/{z}/{x}/{y}.pbf?key=XAapkmkXQpx839NCfnxD"));

    return result;
}

6.2 mapbox加载osm-liberty.json

6.2.1 html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <title>Add a vector tile source</title>
    <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
    <link href="https://api.mapbox.com/mapbox-gl-js/v2.8.2/mapbox-gl.css" rel="stylesheet">
    <script src="https://api.mapbox.com/mapbox-gl-js/v2.8.2/mapbox-gl.js"></script>
    <style>
        body {
            margin: 0;
            padding: 0;
        }

        #map {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script>
        mapboxgl.accessToken = 'pk.eyJ1Ijoid3lqcSIsImEiOiJjbDBnZDdwajUxMXRzM2htdWxubDh1MzJrIn0.2e2_rdU2nOUvtwltBIZtZg';
        const map = new mapboxgl.Map({
            container: 'map',
			style: "http://localhost:9000/tiles/mapbox/liberty.json",
            zoom: 5,
            center: [118.447303, 30.753574]
        });

    </script>

</body>

</html>

6.2.2 效果展示

可以看到所有数据(边界轮廓等)都加载出来了

image-20230616225400563