How it works(12) Tileserver-GL源码
引入
Tileserver-GL(以下简称tileserver),klokantech公司出品的nodejs编写的地图服务,也是我们已经用于生产环境的地图服务.它是少有的开箱即用的且带有图形界面的轻量级地图服务了,这也是我们能快速的将它应用于实际的原因.
Tileserver方便且美观的地图渲染且能与maputnik完美结合,这几乎是我第一次找到能完全替代geoserver的组合,而它同时也包含了矢量瓦片的一些功能.
未来的地图注定是矢量的,当然,栅格的地图瓦片在相当一段时间里不会消失.轻量级的tileserver基本上同时满足这两个需求.
Tileserver的特点
- 比较完善的矢量瓦片支持:(pbf瓦片,pbf字体,渲染配置文件,雪碧图)
- 优秀的栅格瓦片渲染效果:基于mapbox-gl-native.
为什么要阅读tileserver
我主要是带着两个问题去阅读tileserver的源码:
- tileserver如何初始化配置
- 整体布局的思考
代码架构
惯例是madge生成的结构图.虽然外部结构简单,但内部却蛮复杂的.
main.js
main.js
是整个应用的入口,主要实现的就是读取命令行参数,整理准备这些参数从而运行服务.
已经预设了一些参数,比如:
- 默认8080端口
- 默认配置文件是当前目录下的
config.json
- 默认的路径就是当前目录
在利用command
模块读取命令行参数后,第一部就是根据参数读取配置文件:
fs.stat(path.resolve(opts.config), function(err, stats) {
//当不存在给定的配置文件时
if (err || !stats.isFile() || stats.size === 0) {
//就默认存在给定的mbtiles文件
var mbtiles = opts.mbtiles;
//如果连mbtile文件都不存在
if (!mbtiles) {
//就在本文件夹下找存在的mbtiles
var files = fs.readdirSync(process.cwd());
for (var i=0; i < files.length; i++) {
var filename = files[i];
if (filename.endsWith('.mbtiles')) {
var mbTilesStats = fs.statSync(filename);
if (mbTilesStats.isFile() && mbTilesStats.size > 0) {
mbtiles = filename;
break;
}
}
}
//如果本身定义了mbtiles或找到了文件夹下的mbtiles
if (mbtiles) {
//使用mbtiles启动
return startWithMBTiles(mbtiles);
} else {
//本文件夹下不存在就下载特定的mbtiles文件
var url = 'https://github.com/klokantech/tileserver-gl/releases/download/v1.3.0/zurich_switzerland.mbtiles';
var filename = 'zurich_switzerland.mbtiles';
var stream = fs.createWriteStream(filename);
stream.on('finish', function() {
return startWithMBTiles(filename);
});
//使用pipe模式,下载成功就使用mbtiles启动
return request.get(url).pipe(stream);
}
}
if (mbtiles) {
return startWithMBTiles(mbtiles);
}
} else {
//在存在配置文件时,直接传入路径,如果你没指定,默认就算当前目录下的config.json文件
return startServer(opts.config, null);
}
});
实际使用过程中,会发现tileserver就算没有任何参数依然能根据默认的参数,内置的特定DEMO数据和DEMO样式直接运行.
我们不用学习任何配置文件的写法就能直接上手体验tileserver,这比相当多的其他地图服务都离用户近了一步.
可以看出,tileserver支持两种启动模式:
- 配置文件启动(
startServer
) - 单mbtiles文件启动(
startWithMBTiles
)
单mbtiles文件启动时因为没有配置文件的存在,所以需要构建配置对象:
var startWithMBTiles = function(mbtilesFile) {
mbtilesFile = path.resolve(process.cwd(), mbtilesFile);
//mbtile不存在则直接退出
var mbtilesStats = fs.statSync(mbtilesFile);
if (!mbtilesStats.isFile() || mbtilesStats.size === 0) {
process.exit(1);
}
var instance = new mbtiles(mbtilesFile, function(err) {
//mbtiles必须是复合标准的
instance.getInfo(function(err, info) {
if (err || !info) {
process.exit(1);
}
//获取mbtiles所覆盖的范围
var bounds = info.bounds;
//如果没指定配置文件的话,则默认使用指定的DEMO样式
var styleDir = path.resolve(__dirname, "../node_modules/tileserver-gl-styles/");
//构建标准配置文件格式
var config = {
"options": {
"paths": {
"root": styleDir,
"fonts": "fonts",
"styles": "styles",
"mbtiles": path.dirname(mbtilesFile)
}
},
"styles": {},
"data": {}
};
//如果mbtiles是osm标准的矢量瓦片
if (info.format == 'pbf' &&
info.name.toLowerCase().indexOf('openmaptiles') > -1) {
var omtV = (info.version || '').split('.');
config['data']['v' + omtV[0]] = {
"mbtiles": path.basename(mbtilesFile)
};
//获取预定的样式
var styles = fs.readdirSync(path.resolve(styleDir, 'styles'));
for (var i = 0; i < styles.length; i++) {
var styleName = styles[i];
var styleFileRel = styleName + '/style.json';
var styleFile = path.resolve(styleDir, 'styles', styleFileRel);
//判断样式文件是否与mbtiles文件兼容
if (fs.existsSync(styleFile)) {
var styleJSON = require(styleFile);
var omtVersionCompatibility =
((styleJSON || {}).metadata || {})['openmaptiles:version'] || 'x';
var m = omtVersionCompatibility.toLowerCase().split('.');
var isCompatible = !(
m[0] != 'x' && (
m[0] != omtV[0] || (
(m[1] || 'x') != 'x' && (
m[1] != omtV[1] || (
(m[2] || 'x') != 'x' &&
m[2] != omtV[2]
)
)
)
)
);
//不兼容则不加载,这时mbtiles只能输出原始pbf数据而无法进行渲染输出
if (isCompatible) {
var styleObject = {
"style": styleFileRel,
"tilejson": {
"bounds": bounds
}
};
config['styles'][styleName] = styleObject;
}
}
}
} else {
//对于存储栅格瓦片的mbtiles不进行额外的处理,直接当做数据源输出.
config['data'][(info.id || 'mbtiles')
.replace(/\//g, '_')
.replace(/\:/g, '_')
.replace(/\?/g, '_')] = {
"mbtiles": path.basename(mbtilesFile)
};
}
//将构建的配置文件传入startServer
return startServer(null, config);
});
});
};
无论是有配置文件启动还是无配置文件启动,都需要调用startServer
函数.
它接收两个参数,配置文件所在路径和配置文件实体,当然我们已经看到,在实际使用中它们是互斥的:
有配置只会传入配置文件的路径,无配置只会传入自动构建的配置实体:
var startServer = function(configPath, config) {
var publicUrl = opts.public_url;
if (publicUrl && publicUrl.lastIndexOf('/') !== publicUrl.length - 1) {
publicUrl += '/';
}
return require('./server')({
configPath: configPath,
config: config,
bind: opts.bind,
port: opts.port,
cors: opts.cors,
verbose: opts.verbose,
silent: opts.silent,
logFile: opts.log_file,
logFormat: opts.log_format,
publicUrl: publicUrl
});
};
server.js
main.js将参数传给了server.js
.整个服务也就真正开始运行了.
server.js
是整个程序的核心,从结构图上可以看出,它统领了其他的几个模块.
我们先反着来看,从导出开始,看看模块调用了什么,又导出来什么:
module.exports = function(opts) {
//运行整个start函数
var running = start(opts);
//当初始化失败时,打印错误并离开
running.startupPromise.catch(function(err) {
console.error(err.message);
process.exit(1);
});
//接收到停止符号时直接离开
process.on('SIGINT', function() {
process.exit();
});
//接收到SIGHUP信号时,会自动更新配置
process.on('SIGHUP', function() {
//先让服务终止
running.server.shutdown(function() {
//清理引用缓存.
//因为代码不可能修改,所以这里实质上只对配置文件起了作用
for (var key in require.cache) {
delete require.cache[key];
}
//重新开启服务
var restarted = start(opts);
running.server = restarted.server;
running.app = restarted.app;
});
});
return running;
};
导出部分实现了服务的启动,更新以及终结,这些功能都是围绕start
方法来实现的.
start
方法是个巨大的方法,在阅读它之前,需要先看一下,它都引用了哪些库/模块:
//引用的全部系统库
var fs = require('fs'),
path = require('path');
//引用的全部第三方库
var clone = require('clone'),//深拷贝
cors = require('cors'),//express跨域
enableShutdown = require('http-shutdown'),//优雅的关闭HTTP服务
express = require('express'),//express
handlebars = require('handlebars'),//生成HTML的模板库
mercator = new (require('@mapbox/sphericalmercator'))(),//经纬度与行列号转换
morgan = require('morgan');//日志中间件
//引用的全部模块
var packageJson = require('../package'),//应用描述
serve_font = require('./serve_font'),//字体相关接口的模块
serve_rendered = null,
serve_style = require('./serve_style'),//样式文件接口的模块
serve_data = require('./serve_data'),//原始数据接口的凝
utils = require('./utils');//其他工具
var isLight = packageJson.name.slice(-6) == '-light';
if (!isLight) {
//非轻量级下引用渲染模块
serve_rendered = require('./serve_rendered');
}
这里采用了一种归纳式的书写方式,将不同来源的名/库分开引用,布局清晰.
start
函数是一个400多行的大代码块,但其中每个阶段都相互独立,分别实现了各个模块的初始化:
- 初始化与配置文件检查
- 样式初始化
1. 数据
2. 渲染 - 字体与数据的初始化
- 配置文件接口化
- 模板渲染
- 完成初始化并运行
可以看出,这些步骤都是围绕着配置文件来展开的,我们先来看看默认的配置文件都包含了哪些东西:
{
"options": {
"paths": {
"root": "",
"fonts": "fonts",
"sprites": "sprites",
"styles": "styles",
"mbtiles": ""
},
"domains": [
"localhost:8080",
"127.0.0.1:8080"
],
"formatQuality": {
"jpeg": 80,
"webp": 90
},
"maxScaleFactor": 3,
"maxSize": 2048,
"pbfAlias": "pbf",
"serveAllFonts": false,
"serveStaticMaps": true
},
"styles": {
"basic": {
"style": "basic.json",
"tilejson": {
"type": "overlay",
"bounds": [8.44806, 47.32023, 8.62537, 47.43468]
}
},
"hybrid": {
"style": "satellite-hybrid.json",
"serve_rendered": false,
"tilejson": {
"format": "webp"
}
}
},
"data": {
"zurich-vector": {
"mbtiles": "zurich.mbtiles"
}
}
}
配置文件有3大字段:
- options:运行所需的各项参数
- styles:地图的各种样式的描述
- data:地图所用到的各种数据源的描述
这3项的关系是: - options是最基础项,实现data和styles相关的服务的参数都存于options
- data向下需要从options项中获取参数,向上又给styles相关的服务提供数据源.当然,tileserver其实可以当做一个纯粹的数据服务,这时就可以只配置data项.
- styles描述了最终生成地图的样式,它的配置项来源于options,数据源则必须在data项有描述.
1. 初始化与配置文件检查
在一切的开始,初始化express
的一些安全和基础性设置:
//隐藏描述,保证安全
var app = express().disable('x-powered-by'),
serving = {
styles: {},
rendered: {},
data: {},
fonts: {}
};
//信任代理
app.enable('trust proxy');
//测试模式下开启日志记录
if (process.env.NODE_ENV !== 'test') {
var defaultLogFormat = process.env.NODE_ENV == 'production' ? 'tiny' : 'dev';
var logFormat = opts.logFormat || defaultLogFormat;
app.use(morgan(logFormat, {
stream: opts.logFile ? fs.createWriteStream(opts.logFile, { flags: 'a' }) : process.stdout,
skip: function(req, res) { return opts.silent && (res.statusCode == 200 || res.statusCode == 304) }
}));
}
//开启跨域支持
if (opts.cors) {
app.use(cors());
}
接下来是检查路径:
var config = opts.config || null;
var configPath = null;
//检测配置文件是否存在
if (opts.configPath) {
configPath = path.resolve(opts.configPath);
try {
config = clone(require(configPath));
} catch (e) {
process.exit(1);
}
}
//检测配置文件是否为空
if (!config) {
console.log('ERROR: No config file not specified!');
process.exit(1);
}
//检测配置文件的各个配置路径是否有值且存在
var options = config.options || {};
var paths = options.paths || {};
options.paths = paths;
//根目录
paths.root = path.resolve(
configPath ? path.dirname(configPath) : process.cwd(),
paths.root || '');
//根目录下存放样式,雪碧图,字体和mbtiles的文件夹
paths.styles = path.resolve(paths.root, paths.styles || '');
paths.fonts = path.resolve(paths.root, paths.fonts || '');
paths.sprites = path.resolve(paths.root, paths.sprites || '');
paths.mbtiles = path.resolve(paths.root, paths.mbtiles || '');
//构筑检测函数
var checkPath = function(type) {
if (!fs.existsSync(paths[type])) {
process.exit(1);
}
};
//逐一检测
checkPath('styles');
checkPath('fonts');
checkPath('sprites');
checkPath('mbtiles');
//可以定义装饰器函数的路径,估计是对tileserver作者有特殊用途的函数
if (options.dataDecorator) {
try {
options.dataDecoratorFunc = require(path.resolve(paths.root, options.dataDecorator));
} catch (e) {}
}
//当配置和环境都检查妥当,就可以开启准备加载全部模块了
//模块全部采用promise的方法初始化,返回的promise对象就存于此数组
var startupPromises = [];
2. 样式初始化
我们再来回顾一下样式的配置,样式是一个字典,每个样式包含了样式配置文件(一个复杂的json文件)和其他配置:
{
"styles": {
"basic": {
"style": "basic.json",
"tilejson": {
"type": "overlay",
"bounds": [8.44806, 47.32023, 8.62537, 47.43468]
}
},
"hybrid": {
"style": "satellite-hybrid.json",
"serve_rendered": false,
"tilejson": {
"format": "webp"
}
}
}
}
每个样式有两种模式:渲染模式和数据模式.
对于矢量数据源,既可以渲染成栅格瓦片,也可以直接以矢量瓦片的形式发送给支持显示的前端.
对于本身就是栅格瓦片的数据源则只能选择数据模式.
//因为样式与数据源密切相关,因此提前制造一个副本,避免修改产生难以预料的影响
var data = clone(config.data || {});
//遍历所有样式
Object.keys(config.styles || {}).forEach(function(id) {
var item = config.styles[id];
//确保一定有样式配置文件
if (!item.style || item.style.length == 0) {
return;
}
//使用了!==false,确保只在明确设为false的情况下才不执行,不设置(undefined)也会视为true
if (item.serve_data !== false) {
//初始化一个server_style模块,并放入promise数组,这里这个数组相当于一个队列
startupPromises.push(serve_style(options, serving.styles, item, id, opts.publicUrl,
//这个函数会在数据源是mbtiles时调用
//mbtiles是mbtile文件的路径
//fromData标记是否写的不是实际路径而是在data配置项中的键值
function(mbtiles, fromData) {
var dataItemId;
//遍历所有数据源
Object.keys(data).forEach(function(id) {
if (fromData) {
if (id == mbtiles) {
dataItemId = id;
}
} else {
if (data[id].mbtiles == mbtiles) {
dataItemId = id;
}
}
});
//验证样式文件中的数据源是否在配置中存在
if (dataItemId) {
return dataItemId;
} else if (fromData) {
process.exit(1);
} else {
//配置项支持采用下划线的形式使用别名
var id = mbtiles.substr(0, mbtiles.lastIndexOf('.')) || mbtiles;
while (data[id]) id += '_';
data[id] = {
'mbtiles': mbtiles
};
return id;
}
}, function(font) {
//这个函数会在设置样式的字体时调用
serving.fonts[font] = true;
}).then(function(sub) {
//当初始化完毕时,将style的路由挂载到express上
app.use('/styles/', sub);
}));
}
//当数据源支持渲染时
if (item.serve_rendered !== false) {
//在非tiny模式下支持渲染
if (serve_rendered) {
//初始化一个server_render模块,并放入promise队列
startupPromises.push(
serve_rendered(options, serving.rendered, item, id, opts.publicUrl,
function(mbtiles) {
//在数据源是mbtiles时
//同样需要检测mbtiles文件是否存在
var mbtilesFile;
Object.keys(data).forEach(function(id) {
if (id == mbtiles) {
mbtilesFile = data[id].mbtiles;
}
});
return mbtilesFile;
}
).then(function(sub) {
//初始化完成后挂载到路由上
app.use('/styles/', sub);
})
);
} else {
item.serve_rendered = false;
}
}
});
3.字体,数据的初始化
tilserver只对所有在样式中使用的字体进行挂载.
对于数据源,tileserver的style
里其实支持两种:来自mbtiles
文件,或来自http请求.
在这里,tilserver只会将mbtiles
发为服务,而没有多此一举的将来源于网络的数据代理一遍,再发成服务.
startupPromises.push(
serve_font(options, serving.fonts).then(function(sub) {
app.use('/', sub);
})
);
//遍历所有数据源
Object.keys(data).forEach(function(id) {
var item = data[id];
//检测是不是mbtiles数据源
if (!item.mbtiles || item.mbtiles.length == 0) {
return;
}
//初始化数据模块并挂载
startupPromises.push(
serve_data(options, serving.data, item, id, serving.styles, opts.publicUrl).then(function(sub) {
app.use('/data/', sub);
})
);
});
4.配置文件接口化
tileserver将自己所有正在运行的样式,数据源等描述以json接口形式的提供出来,供第三方服务或自己调用:
//所有的样式
app.get('/styles.json', function(req, res, next) {
var result = [];
var query = req.query.key ? ('?key=' + req.query.key) : '';
Object.keys(serving.styles).forEach(function(id) {
var styleJSON = serving.styles[id];
result.push({
version: styleJSON.version,
name: styleJSON.name,
id: id,
url: utils.getPublicUrl(opts.publicUrl, req) +
'styles/' + id + '/style.json' + query
});
});
res.send(result);
});
var addTileJSONs = function(arr, req, type) {
Object.keys(serving[type]).forEach(function(id) {
var info = clone(serving[type][id]);
var path = '';
if (type == 'rendered') {
path = 'styles/' + id;
} else {
path = type + '/' + id;
}
//返回数据的XYZ瓦片格式
info.tiles = utils.getTileUrls(req, info.tiles, path, info.format, opts.publicUrl, {
'pbf': options.pbfAlias
});
arr.push(info);
});
return arr;
};
//所有的渲染样式
app.get('/rendered.json', function(req, res, next) {
res.send(addTileJSONs([], req, 'rendered'));
});
//所有的数据源
app.get('/data.json', function(req, res, next) {
res.send(addTileJSONs([], req, 'data'));
});
app.get('/index.json', function(req, res, next) {
res.send(addTileJSONs(addTileJSONs([], req, 'rendered'), req, 'data'));
});
5.模板渲染
tileserver相当优秀的一点,就是提供了一个简单好用,一目了然的前端界面,极大的降低了用户上手难度.
降低上手难度就是在节省时间,毕竟我们现在很难能承受的起花大量时间学完一个新东西才发现不适合自己.
tileserver的界面很简单,使用的是handlebars
的模板引擎:
//将静态的css,js文件发为服务
app.use('/', express.static(path.join(__dirname, '../public/resources')));
var templates = path.join(__dirname, '../public/templates');
//模板渲染函数
//参数:待挂载地址,模板名,数据预处理函数
var serveTemplate = function(urlPath, template, dataGetter) {
var templateFile = templates + '/' + template + '.tmpl';
if (template == 'index') {
if (options.frontPage === false) {
return;
} else if (options.frontPage &&
options.frontPage.constructor === String) {
templateFile = path.resolve(paths.root, options.frontPage);
}
}
startupPromises.push(new Promise(function(resolve, reject) {
fs.readFile(templateFile, function(err, content) {
//导入模板的原始数据进行预编译
var compiled = handlebars.compile(content.toString());
//挂载到指定路由
app.use(urlPath, function(req, res, next) {
var data = {};
//如果是需要根据请求内容动态修改的模板,需要通过预处理函数进行生成动态数据
if (dataGetter) {
data = dataGetter(req);
if (!data) {
return res.status(404).send('Not found');
}
}
//传入一些通用信息
data['server_version'] = packageJson.name + ' v' + packageJson.version;
data['public_url'] = opts.publicUrl || '/';
data['is_light'] = isLight;
data['key_query_part'] =
req.query.key ? 'key=' + req.query.key + '&' : '';
data['key_query'] = req.query.key ? '?key=' + req.query.key : '';
if (template === 'wmts') res.set('Content-Type', 'text/xml');
//最终将模板编译成html代码,传给前端
return res.status(200).send(compiled(data));
});
resolve();
});
}));
};
以styles
页面为例,看看serveTemplate
函数如何渲染的:
serveTemplate('/styles/:id/$', 'viewer', function(req) {
var id = req.params.id;
var style = clone((config.styles || {})[id]);
if (!style) {
return null;
}
style.id = id;
style.name = (serving.styles[id] || serving.rendered[id]).name;
style.serving_data = serving.styles[id];
style.serving_rendered = serving.rendered[id];
return style;
});
可以看出,viewer
模板需要渲染到style
页面上,页面的内容需要根据id不同而变化,因此实现了dataGetter
函数,动态生成了模板内容.
6.完成初始化并运行
初始化步骤都进入了队列,只需静等他们完成,一切就都开始运转了:
//初始化完成标志
var startupComplete = false;
//各个初始化步骤没有先后顺序,是并行初始化的
var startupPromise = Promise.all(startupPromises).then(function() {
startupComplete = true;
});
//健康检测,其实就是初始化是否完成
app.get('/health', function(req, res, next) {
if (startupComplete) {
return res.status(200).send('OK');
} else {
return res.status(503).send('Starting');
}
});
//监听指定/预设的端口
var server = app.listen(process.env.PORT || opts.port, process.env.BIND || opts.bind, function() {
var address = this.address().address;
if (address.indexOf('::') === 0) {
address = '[' + address + ']'; //IPV6地址
}
console.log('Listening at http://%s:%d/', address, this.address().port);
});
// 当遇到特殊情况时,可以优雅的关闭服务,释放占用的资源和接口
enableShutdown(server);
return {
app: app,
server: server,
startupPromise: startupPromise
};