1.mbtiles简介#
mapbox Docs : MBTiles
MbTiles 是一种用于在 sqllite 数据库中存储任意瓦片地图数据用于即时使用和高效传输的规范。
MBTiles瓦片存储规范的制定主要是为了解决、优化传统瓦片的存储方案存在的两个问题:
- 可移植性差,无法在移动端上做离线应用
- 存储量大,大家都知道,因为互联网上的地图都以“瓦片”的形式存在,高层级的瓦片存储数量往往是海量的。例如,对于“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.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表的瓦片图片数据关联起来
视图基本结构:
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只是对图片二进制处理,并没有压缩
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 效果展示#
可以看到水体被加载出来了
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.json
的tiles
就是我们[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 效果展示#
可以看到所有数据(边界轮廓等)都加载出来了