How it works(13) Tileserver-GL源码
serve_rendered.js
是什么使
tileserver
如此的无可替代?是他的栅格瓦片渲染.
当Tilestrata
和Tilestache
还在用需要复杂配置文件的mapnik
时,tileserver
却将web页面的mapbox
直接搬到了服务端,达到了前后端配置文件与效果的完全统一,在maputnik
的帮助下,样式的调整也变得方便异常.
这就造就了整个tileserver
里最大的模块:server_render.js
,一个接近800行的大模块,比前面的main.js
模块加上render.js
模块还大.
首先看一下都引用了哪些模块:
var advancedPool = require('advanced-pool'),//用于资源调度
fs = require('fs'),
path = require('path'),
url = require('url'),
util = require('util'),
zlib = require('zlib');
//将二进制流转换成图像
var sharp = require('sharp');
var Canvas = require('canvas'),//绘制图像
clone = require('clone'),//深拷贝
Color = require('color'),//封装色彩相关方法
express = require('express'),
mercator = new (require('@mapbox/sphericalmercator'))(),//封装瓦片计算的相关方法
mbgl = require('@mapbox/mapbox-gl-native'),//渲染图片的核心模块
mbtiles = require('@mapbox/mbtiles'),//操作mbtiles
proj4 = require('proj4'),//投影转换
request = require('request');
辅助方法与变量
在正式进入redered名之前,还有一些预定义方法和变量:
//识别小数的正则表达式
var FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+\.?\\d+)';
//获取请求的dpi级别
var getScale = function(scale) {
return (scale || '@1x').slice(1, 2) | 0;
};
//后缀名转换字典
var extensionToFormat = {
'.jpg': 'jpeg',
'.jpeg': 'jpeg',
'.png': 'png',
'.webp': 'webp'
};
当渲染或其他细节出错时,返回一张有着相近颜色的纯色瓦片相比不返回是更合适的选择:
var cachedEmptyResponses = {
'': new Buffer(0)
};
function createEmptyResponse(format, color, callback) {
//当请求的是pbf或未指定格式时,返回空流
if (!format ||coui format === 'pbf') {
callback(null, {data: cachedEmptyResponses['']});
return;
}
if (format === 'jpg') {
format = 'jpeg';
}
if (!color) {
color = 'rgba(255,255,255,0)';
}
//如果命中缓存就直接返回缓存
var cacheKey = format + ',' + color;
var data = cachedEmptyResponses[cacheKey];
if (data) {
callback(null, {data: data});
return;
}
//否则就构建缓存
var color = new Color(color);
var array = color.array();
var channels = array.length == 4 && format != 'jpeg' ? 4 : 3;
sharp(new Buffer(array), {
raw: {
width: 1,
height: 1,
channels: channels
}
}).toFormat(format).toBuffer(function(err, buffer, info) {
//无误就缓存起来
if (!err) {
cachedEmptyResponses[cacheKey] = buffer;
}
callback(null, {data: buffer});
});
}
rendered方法
先来回顾一下,server_render函数是在什么环境下调用的:
server.js
Object.keys(config.styles || {}).forEach(function(id) {
var item = config.styles[id];
/*...*/
if (item.serve_data !== false)
startupPromises.push(
serve_rendered(options, serving.rendered, item, id, opts.publicUrl,
function(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);
})
);
}
面对每一个需要渲染的样式,都会调用一遍这个函数.也就是说,每一个样式之间都是彼此独立的一个复杂对象.
这个巨大的方法可以分为两个阶段:
- 工作前为渲染做准备
- 工作中如何渲染瓦片
在整个流程中,可以发现一些与
server.js
相像的步骤,其实server_redered.js
模块相当于将在浏览器那一套搬到了服务端运行,所以所需的资源是一样的:字体,样式,雪碧图等,都会在server_redered.js
里得到体现.
准备
- 一些散碎的变量与前期准备:
//一如既往的安全措施
var app = express().disable('x-powered-by');
//生成支持的dpi缩放级别
var maxScaleFactor = Math.min(Math.floor(options.maxScaleFactor || 3), 9);
var scalePattern = '';
for (var i = 2; i <= maxScaleFactor; i++) {
scalePattern += i.toFixed();
}
scalePattern = '@[' + scalePattern + ']x';
//服务启动日期,用于缓存过期判断
var lastModified = new Date().toUTCString();
//水印
var watermark = params.watermark || options.watermark;
var styleFile = params.style;
var map = {
renderers: [],
sources: {}
};
- 字体检测
var existingFonts = {};
var fontListingPromise = new Promise(function(resolve, reject) {
//遍历字体文件夹
fs.readdir(options.paths.fonts, function(err, files) {
if (err) {
reject(err);
return;
}
files.forEach(function(file) {
fs.stat(path.join(options.paths.fonts, file), function(err, stats) {
if (err) {
reject(err);
return;
}
//字体都以文件夹存在
//记录存在的字体
if (stats.isDirectory()) {
existingFonts[path.basename(file)] = true;
}
});
});
resolve();
});
});
- 样式加载
这里的样式就是配置文件里样式文件夹指向的具体每一个样式文件,以官方的样式Klokantech Basic
为例:
{
"version": 8,
"name": "Klokantech Basic",
"metadata": {
"mapbox:autocomposite": false,
"mapbox:type": "template",
"maputnik:renderer": "mbgljs",
"openmaptiles:version": "3.x",
"openmaptiles:mapbox:owner": "openmaptiles",
"openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t"
},
"center": [
8.54806714892635,
47.37180823552663
],
"zoom": 12.241790506353492,
"bearing": 0,
"pitch": 0,
"sources": {
"openmaptiles": {
"type": "vector",
"url": "mbtiles://{v3}"
}
},
"glyphs": "{fontstack}/{range}.pbf",
"sprite": "{styleJsonFolder}/sprite",
"layers": [......省略全部图层]
}
这里的语法与mapbox-gl的配置完全兼容,包括元数据描述以及具体每一个图层的描述.
图层描述完全交给渲染引擎,我们不去管他,但对于某些东西需要我们为渲染引擎准备好:
- 地理数据
- 雪碧图
- 字体
所谓准备好,就是把存于本地的不同文件夹下的这些资源,在引擎需要的时候让引擎能够找到,当然,如果这些资源本就存于网络,直接通过请求就能获取,不用这么麻烦了.
首先是处理字体与雪碧图,需要统一资源路径表达式:
//制作副本,不对原始文件修改
var styleJSONPath = path.resolve(options.paths.styles, styleFile);
styleJSON = clone(require(styleJSONPath));
//如果是网络资源,则无需修改
var httpTester = /^(http(s)?:)?\/\//;
//否则就替换为protocol形式的资源描述表达式
if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) {
//替换表达式内的动态内容为实际路径
styleJSON.sprite = 'sprites://' +
styleJSON.sprite
.replace('{style}', path.basename(styleFile, '.json'))
.replace('{styleJsonFolder}', path.relative(options.paths.sprites, path.dirname(styleJSONPath)));
}
//对待字体同样替换为protocol形式
if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) {
styleJSON.glyphs = 'fonts://' + styleJSON.glyphs;
mbtiles格式的地理数据比较复杂,需要专门处理;
var queue = [];
//初始化每一个数据源
Object.keys(styleJaSON.sources).forEach(function(name) {
var source = styleJSON.sources[name];
var url = source.url;
//对于那些存储于mbtiles的数据源
if (url && url.lastIndexOf('mbtiles:', 0) === 0) {
delete source.url;
var mbtilesFile = url.substring('mbtiles://'.length);
//支持数据源别名
var fromData = mbtilesFile[0] == '{' &&
mbtilesFile[mbtilesFile.length - 1] == '}';
if (fromData) {
mbtilesFile = mbtilesFile.substr(1, mbtilesFile.length - 2);
var mapsTo = (params.mapping || {})[mbtilesFile];
if (mapsTo) {
mbtilesFile = mapsTo;
}
mbtilesFile = dataResolver(mbtilesFile);
if (!mbtilesFile) {
console.error('ERROR: data "' + mbtilesFile + '" not found!');
process.exit(1);
}
}
//放入异步初始化队列中
queue.push(new Promise(function(resolve, reject) {
mbtilesFile = path.resolve(options.paths.mbtiles, mbtilesFile);
var mbtilesFileStats = fs.statSync(mbtilesFile);
if (!mbtilesFileStats.isFile() || mbtilesFileStats.size == 0) {
throw Error('Not valid MBTiles file: ' + mbtilesFile);
}
//获取每个mbtiles数据源的信息
map.sources[name] = new mbtiles(mbtilesFile, function(err) {
map.sources[name].getInfo(function(err, info) {
if (err) {
return;
}
//支持投影转换
if (!dataProjWGStoInternalWGS && info.proj4) {
var to3857 = proj4('EPSG:3857');
var toDataProj = proj4(info.proj4);
dataProjWGStoInternalWGS = function(xy) {
return to3857.inverse(toDataProj.forward(xy));
};
}
var type = source.type;
//将mbtiles的元信息并入
Object.assign(source, info);
source.type = type;
source.tiles = [
'mbtiles://' + name + '/{z}/{x}/{y}.' + (info.format || 'pbf')
];
resolve();
});
});
}));
}
});
引擎是这样调用protocol格式的资源的:
var createPool = function(ratio, min, max) {
var createRenderer = function(ratio, createCallback) {
//初始化渲染引擎
var renderer = new mbgl.Map({
//放大倍率,如2.0一般对应高dpi
ratio: ratio,
request: function(req, callback) {
//处理不同的资源类型
var protocol = req.url.split(':')[0];
if (protocol == 'sprites') {
var dir = options.paths[protocol];
var file = unescape(req.url).substring(protocol.length + 3);
//返回文件流
fs.readFile(path.join(dir, file), function(err, data) {
callback(err, { data: data });
});
} else if (protocol == 'fonts') {
var parts = req.url.split('/');
var fontstack = unescape(parts[2]);
var range = parts[3].split('.')[0];
//这个函数可以将请求的多个字体文件合为个文件返回
utils.getFontsPbf(
null, options.paths[protocol], fontstack, range, existingFonts
).then(function(concated) {
callback(null, {data: concated});
}, function(err) {
callback(err, {data: null});
});
} else if (protocol == 'mbtiles') {
var parts = req.url.split('/');
var sourceId = parts[2];
var source = map.sources[sourceId];
var sourceInfo = styleJSON.sources[sourceId];
var z = parts[3] | 0,
x = parts[4] | 0,
y = parts[5].split('.')[0] | 0,
format = parts[5].split('.')[1];
//从mbtiles文件获取瓦片
source.getTile(z, x, y, function(err, data, headers) {
//如果获取错误,就返回纯色空瓦片
if (err) {
createEmptyResponse(sourceInfo.format, sourceInfo.color, callback);
return;
}
var response = {};
if (headers['Last-Modified']) {
response.modified = new Date(headers['Last-Modified']);
}
if (format == 'pbf') {
try {
response.data = zlib.unzipSync(data);
}
catch (err) {
console.log("Skipping incorrect header for tile mbtiles://%s/%s/%s/%s.pbf", id, z, x, y);
}
if (options.dataDecoratorFunc) {
response.data = options.dataDecoratorFunc(
sourceId, 'data', response.data, z, x, y);
}
} else {
response.data = data;
}
callback(null, response);
});
} else if (protocol == 'http' || protocol == 'https') {
//对于一切网络资源(字体,雪碧图,地理数据),都使用请求模式
request({
url: req.url,
encoding: null,
gzip: true
}, function(err, res, body) {
var parts = url.parse(req.url);
var extension = path.extname(parts.pathname).toLowerCase();
var format = extensionToFormat[extension] || '';
if (err || res.statusCode < 200 || res.statusCode >= 300) {
//出错就返回空透明的瓦片(对应地图瓦片请求)或空流(对应字体,雪碧图等)
createEmptyResponse(format, '', callback);
return;
}
var response = {};
if (res.headers.modified) {
response.modified = new Date(res.headers.modified);
}
if (res.headers.expires) {
response.expires = new Date(res.headers.expires);
}
if (res.headers.etag) {
response.etag = res.headers.etag;
}
response.data = body;
callback(null, response);
});
}
}
});
//引擎加载样式文件
renderer.load(styleJSON);
createCallback(null, renderer);
};
//advancedPool提供了一种资源调度池:
//只能创建给定范围内数量的对象,多了就会排队等池中的资源可用为之
//可以分配计算资源
return new advancedPool.Pool({
min: min,
max: max,
create: createRenderer.bind(null, ratio),
destroy: function(renderer) {
renderer.release();
}
});
};
把资源初始化和引擎调用打包成一个promise:
var renderersReadyPromise = Promise.all(queue).then(function() {
//标准dpi缩放和2倍缩放最常用,所以默认给更多的资源
var minPoolSizes = options.minRendererPoolSizes || [8, 4, 2];
var maxPoolSizes = options.maxRendererPoolSizes || [16, 8, 4];
for (var s = 1; s <= maxScaleFactor; s++) {
var i = Math.min(minPoolSizes.length - 1, s - 1);
var j = Math.min(maxPoolSizes.length - 1, s - 1);
var minPoolSize = minPoolSizes[i];
var maxPoolSize = Math.max(minPoolSize, maxPoolSizes[j]);
//每个dpi级别的渲染都是权重不同的一个资源池
map.renderers[s] = createPool(s, minPoolSize, maxPoolSize);
}
});
至此,初始化就告一段落了.
渲染
如何将对应经纬度的瓦片渲染出来是首要解决的问题:
var respondImage = function(z, lon, lat, bearing, pitch,
width, height, scale, format, res, next,
opt_overlay) {
//参数验证
if (Math.abs(lon) > 180 || Math.abs(lat) > 85.06 ||
lon != lon || lat != lat) {
return res.status(400).send('Invalid center');
}
if (Math.min(width, height) <= 0 ||
Math.max(width, height) * scale > (options.maxSize || 2048) ||
width != width || height != height) {
return res.status(400).send('Invalid size');
}
//格式验证
if (format == 'png' || format == 'webp') {
} else if (format == 'jpg' || format == 'jpeg') {
format = 'jpeg';
} else {
return res.status(400).send('Invalid format');
}
//从特定的资源池获取瓦片
var pool = map.renderers[scale];
pool.acquire(function(err, renderer) {
var mbglZ = Math.max(0, z - 1);
var params = {
zoom: mbglZ,
center: [lon, lat],
bearing: bearing,
pitch: pitch,
width: width,
height: height
};
//当0级时自动放大2倍,否则就太小了
if (z == 0) {
params.width *= 2;
params.height *= 2;
}
//按照参数渲染
renderer.render(params, function(err, data) {
//完成后释放资源,让给下一个
pool.release(renderer);
if (err) {
console.error(err);
return;
}
//生成的二进制流渲染成对应格式与尺寸
var image = sharp(data, {
raw: {
width: params.width * scale,
height: params.height * scale,
channels: 4
}
});
if (z == 0) {
//当0级时,调整图像为512x256,因为这时是一个长条状的世界地图
image.resize(width * scale, height * scale);
}
//有背景就渲染背景
if (opt_overlay) {
image.overlayWith(opt_overlay);
}
//用node-Canvas绘制文字水印
if (watermark) {
var canvas = new Canvas(scale * width, scale * height);
var ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.font = '10px sans-serif';
ctx.strokeWidth = '1px';
ctx.strokeStyle = 'rgba(255,255,255,.4)';
ctx.strokeText(watermark, 5, height - 5);
ctx.fillStyle = 'rgba(0,0,0,.4)';
ctx.fillText(watermark, 5, height - 5);
image.overlayWith(canvas.toBuffer());
}
//输出图像
var formatQuality = (params.formatQuality || {})[format] ||
(options.formatQuality || {})[format];
if (format == 'png') {
image.png({adaptiveFiltering: false});
} else if (format == 'jpeg') {
image.jpeg({quality: formatQuality || 80});
} else if (format == 'webp') {
image.webp({quality: formatQuality || 90});
}
image.toBuffer(function(err, buffer, info) {
if (!buffer) {
return res.status(404).send('Not found');
}
res.set({
'Last-Modified': lastModified,
'Content-Type': 'image/' + format
});
return res.status(200).send(buffer);
});
});
});
};
接下来是它的调用:
var tilePattern = '/' + id + '/:z(\\d+)/:x(\\d+)/:y(\\d+)' +
':scale(' + scalePattern + ')?\.:format([\\w]+)';
//挂载路由到瓦片请求上
app.get(tilePattern, function(req, res, next) {
var modifiedSince = req.get('if-modified-since'), cc = req.get('cache-control');
//允许使用浏览器缓存
if (modifiedSince && (!cc || cc.indexOf('no-cache') == -1)) {
if (new Date(lastModified) <= new Date(modifiedSince)) {
return res.sendStatus(304);
}
}
var z = req.params.z | 0,
x = req.params.x | 0,
y = req.params.y | 0,
scale = getScale(req.params.scale),
format = req.params.format;
if (z < 0 || x < 0 || y < 0 ||
z > 20 || x >= Math.pow(2, z) || y >= Math.pow(2, z)) {
return res.status(404).send('Out of bounds');
}
var tileSize = 256;
//行列号转坐标
//再用像素坐标转经纬度
//求出中心点
//相当于((x + 0.5) / Math.pow(2,z)) *256* Math.pow(2,z)
var tileCenter = mercator.ll([
((x + 0.5) / (1 << z)) * (256 << z),
((y + 0.5) / (1 << z)) * (256 << z)
], z);
return respondImage(z, tileCenter[0], tileCenter[1], 0, 0,
tileSize, tileSize, scale, format, res, next);
});
还支持静态瓦片的渲染,由于不常用,暂且不提.
tile_server
还提供了每种样式的元数据接口供第三方查看调用:
var tileJSON = {
'tilejson': '2.0.0',
'name': styleJSON.name,
'attribution': '',
'minzoom': 0,
'maxzoom': 20,
'bounds': [-180, -85.0511, 180, 85.0511],
'format': 'png',
'type': 'baselayer'
};
Object.assign(tileJSON, params.tilejson || {});
tileJSON.tiles = params.domains || options.domains;
//修改tilejson的四至为真实值
utils.fixTileJSONCenter(tileJSON);
//挂载到路由上
app.get('/' + id + '.json', function(req, res, next) {
var info = clone(tileJSON);
//动态生成该样式的调用地址
info.tiles = utils.getTileUrls(req, info.tiles,'styles/' + id, info.format,ublicUrl);
return res.send(info);
});
至此,庞大的函数就真正完成了使命:
//等待一切初始化完成后返回已经将各种方法挂载完毕的app对象
return Promise.all([fontListingPromise, renderersReadyPromise]).then(function() {
return app;
});