這篇筆記想寫很久了,但是作者最近人生大事不少,耽擱了一陣子。本篇不續接上篇streetscape.gl學習筆記(三)
內容,上篇未完內容將在原篇上進行更新。本篇主要介紹streetscape.gl Loaders。
為何突然轉到該主題上,皆因作者最近倒騰的一個小案例。使用React+TypeScript,搭建一個捕魚船舶看板。這是一個非常典型的物聯網展示應用,自己以前也的項目重構記中也介紹了如何通過Kafka+WebSocket實現后臺消息實時推送到前端展示。于是作者就想看看streetscape.gl其作為車輛信號展示看板它是如何實現的。
首先從我們最常見的場景,實時顯示小車定位的XVIZLiveLoader看起。官網文檔是這么講的:
XVIZLiveLoader
Connects to a live XVIZ system using a WebSocket. Implements XVIZLoaderInterface.
XVIZLiveLoader是通過WebSocket來連接一個實時傳輸XVIZ協議數據的類,其繼承自XVIZLoaderInterface.
A live XVIZ system describes a running system does not have a start and end time available in the metadata and will send XVIZ data immediately upon connection.
一個實時的XVIZ系統描述了一個正在運行的系統,其元數據中沒有可以獲得的起止時間,并且一旦連接成功就會發送XVIZ數據。
The XVIZLiveLoader will also immediately update the scene to the timestamp of the latest received XVIZ message.
當接收到最新的XVIZ消息時,XVIZLiveLoader也將立即更新場景。
Constructor
import {XVIZLiveLoader} from 'streetscape.gl';
new XVIZLiveLoader({
serverConfig: {
serverUrl: 'ws://localhost:8081'
}
});
Options
serverConfig (Object)
serverConfig.serverUrl (String) - url of the WebSocket server
serverConfig.queryParams (Object, optional) - additional query parameters to use when connecting to the server
serverConfig.defaultLogLength (Number, optional) - fallback value if the duration option is not specified.
serverConfig.retryAttempts (Number, optional) - number of retries if a connection error is encountered. Default 3.
logProfile (String, optional) - Name of the profile to load the log with
bufferLength (Number, optional) - the length of the buffer to keep in memory. Uses the same unit as timestamp. If specified, older frames may be discarded during playback, to avoid crashes due to excessive memory usage. Default 30 seconds.
worker (String|Boolean, optional) - Use a worker for message processing. Default true.
Type Boolean: enable/disable default worker
Type String: the worker URL to use
maxConcurrency (Number, optional) - the maximum number of worker threads to spawn. Default 3.
如何使用
在examples/get-started中看看
app.js中
// __IS_STREAMING__ and __IS_LIVE__ are defined in webpack.config.js
const exampleLog = require(__IS_STREAMING__
? './log-from-stream'
: __IS_LIVE__
? './log-from-live'
: './log-from-file').default;
通過webpack.config.js中的IS_STREAMING 和 IS_LIVE配置來判斷該次使用的Loader,這里我們看看log-from-live。
log-from-live.js
import {XVIZLiveLoader} from 'streetscape.gl';
export default new XVIZLiveLoader({
logGuid: 'mock',
bufferLength: 10,
serverConfig: {
defaultLogLength: 30,
serverUrl: 'ws://localhost:8081'
},
worker: true,
maxConcurrency: 4
});
從這個示例中可以看出XVIZLiveLoader如何使用,需要配置一些參數。
logGuid:該數據源的guid號
bufferLength:在內存存放多長buffer的數據,和時間戳有相同的時間單位。如果指定的話,當回放時舊幀將會被拋棄以避免浪費內存。默認值是30s。
serverConfig.defaultLogLength:當duration參數沒被指定時,設定回退的值。
serverConfig.serverUrl:WebSocket server的url地址
worker:是否使用worker線程來進行消息處理
maxConcurrency:最大worker線程數
exampleLog獲得一個XVIZLiveLoader類型的實例后,如何使用的呢。在app.js中我們可以看到
class Example extends PureComponent {
state = {
log: exampleLog,
settings: {
viewMode: 'PERSPECTIVE',
showTooltip: false
}
};
componentDidMount() {
this.state.log.on('error', console.error).connect();
}
...
render() {
const {log, settings} = this.state;
return (
<div id="container">
<div id="control-panel">
<XVIZPanel log={log} name="Metrics" />
<hr />
<XVIZPanel log={log} name="Camera" />
<hr />
<Form
data={APP_SETTINGS}
values={this.state.settings}
onChange={this._onSettingsChange}
/>
<StreamSettingsPanel log={log} />
</div>
...
</div>
)}
}
它被傳入state中,當組件掛載完成時,log開始嘗試連接并監聽error消息。
隨后,在render中又作為屬性傳遞給其他組件。那其他組件拿到log后做了些什么呢。我們可以看一下核心組件LogViewer是怎么對待log的。
在modules/core/src/components/log-viewer/index.js中我們可以看到
const getLogState = log => ({
frame: log.getCurrentFrame(),
metadata: log.getMetadata(),
streamsMetadata: log.getStreamsMetadata()
});
export default connectToLog({getLogState, Component: LogViewer});
其從log中獲取當前幀、元數據及數據流的元數據,并將其與組件進行綁定,生成一個高階組件。
如何實現
看完如何使用,我們不禁想問如何實現的。我們從獲取當前幀開始,在這之前我們要了解新建一個XVIZLiveLoader實例、組件掛載后實現connect連接做了什么
XVIZLiveLoader實例
new XVIZLiveLoader({
logGuid: 'mock',
bufferLength: 10,
serverConfig: {
defaultLogLength: 30,
serverUrl: 'ws://localhost:8081'
},
worker: true,
maxConcurrency: 4
});
定位到xviz-live-loader.js文件中
/*
* Handle connecting to XVIZ socket and negotiation of the XVIZ protocol version
*
* This loader is used when connecting to a "live" XVIZ websocket.
* This implies that the metadata does not have a start or end time
* and that we want to display the latest message as soon as it arrives.
*/
export default class XVIZLiveLoader extends XVIZWebsocketLoader {
/**
* constructor
* @params serverConfig {object}
* - serverConfig.serverUrl {string}
* - serverConfig.defaultLogLength {number, optional} - default 30
* - serverConfig.queryParams {object, optional}
* - serverConfig.retryAttempts {number, optional} - default 3
* @params worker {string|function, optional}
* @params maxConcurrency {number, optional} - default 3
* @params logProfile {string, optional}
* @params bufferLength {number, optional}
*/
constructor(options = {}) {
super(options);
// Construct websocket connection details from parameters
this.requestParams = getSocketRequestParams(options);
assert(this.requestParams.bufferLength, 'bufferLength must be provided');
this.retrySettings = {
retries: this.requestParams.retryAttempts,
minTimeout: 500,
randomize: true
};
// Setup relative stream buffer storage by splitting bufferLength 1/3 : 2/3
const bufferChunk = this.requestParams.bufferLength / 3;
// Replace base class object
this.streamBuffer = new XVIZStreamBuffer({
startOffset: -2 * bufferChunk,
endOffset: bufferChunk
});
}
...
}
可以看到XVIZLiveLoader繼承自XVIZWebsocketLoader
- 其構造函數首先調用父類的構造函數
- 通過getSocketRequestParams構造websocket連接參數
- 設置websocket嘗試連接次數、時長
- 設置相關流緩沖存儲,講緩沖長度切割成1/3 : 2/3
- 新建一個XVIZStreamBuffer以代替基類對象
這里可以看看XVIZStreamBuffer,其定義是
XVIZStreamBuffer
The XVIZStreamBuffer
class manages loaded XVIZ timeslices in memory for easy access.
Constructor
const streamBuffer = new XVIZStreamBuffer();
Parameters:
-
options (Object)
-
startOffset (Number) - offset in seconds. if provided, will not keep timeslices earlier than
currentTime - startOffset
. Defaultnull
. -
endOffset (Number) - offset in seconds. if provided, will not keep timeslices later than
currentTime + endOffset
. Defaultnull
. -
maxLength (Number) - length in seconds. if provided, the buffer will be forced to be no
bigger than the specified length. Defaultnull
.
-
startOffset (Number) - offset in seconds. if provided, will not keep timeslices earlier than
There are three types of buffer: unlimited, offset, and fixed. Use the constructor options to set
an offset buffer (relative to playhead). To set a fixed buffer with absolute timestamps, see
setFixedBuffer
.
可以看到XVIZStreamBuffer是用以加載XVIZ數據時間片到內存中以方便訪問,而這里的startOffset、endOffset用以控制在內存中駐留的時間片長度。
再定位到xviz-websocket-loader.js看看XVIZWebsocketLoader的定義
/**
* Connect to XVIZ 2 websocket manage storage of XVIZ data into a XVIZStreamBuffer
*
* This class is a Websocket base class and is expected to be subclassed with
* the following methods overridden:
*
* - _onOpen()
*/
export default class XVIZWebsocketLoader extends XVIZLoaderInterface {
/**
* constructor
* @params serverConfig {object}
* - serverConfig.serverUrl {string}
* - serverConfig.defaultLogLength {number, optional} - default 30
* - serverConfig.queryParams {object, optional}
* - serverConfig.retryAttempts {number, optional} - default 3
* @params worker {string|function, optional}
* @params maxConcurrency {number, optional} - default 3
* @params debug {function} - Debug callback for the XVIZ parser.
* @params logGuid {string}
* @params logProfile {string, optional}
* @params duration {number, optional}
* @params timestamp {number, optional}
* @params bufferLength {number, optional}
*/
constructor(options = {}) {
super(options);
this.socket = null;
this.retrySettings = {
retries: 3,
minTimeout: 500,
randomize: true
};
this.streamBuffer = new XVIZStreamBuffer();
// Handler object for the websocket events
// Note: needs to be last due to member dependencies
this.WebSocketClass = options.WebSocketClass || WebSocket;
}
...
}
可以看到XVIZWebsocketLoader 繼承自XVIZLoaderInterface
- 其構造函數首先調用父類的構造函數
- 設置websocket嘗試連接次數、時長
- 定義處理websocket事件的類
再定位到xviz-loader-interface.js看看XVIZLoaderInterface的定義
export default class XVIZLoaderInterface {
constructor(options = {}) {
this.options = options;
this._debug = options.debug || (() => {});
this.callbacks = {};
this.listeners = [];
this.state = {};
this._updates = 0;
this._version = 0;
this._updateTimer = null;
}
...
}
這個構造函數相對簡單,主要是初始化一些參數,如:事件回調、監聽數組、數據版本等
connect
在組件掛載函數中,調用了connect和on函數
componentDidMount() {
this.state.log.on('error', console.error).connect();
}
這里我們定位到xviz-websocket-loader.js中connect函數定義
/**
* Open an XVIZ socket connection with automatic retry
*
* @returns {Promise} WebSocket connection
*/
connect() {
assert(this.socket === null, 'Socket Manager still connected');
this._debug('stream_start');
const {url} = this.requestParams;
// Wrap retry logic around connection
return PromiseRetry(retry => {
return new Promise((resolve, reject) => {
try {
const ws = new this.WebSocketClass(url);
ws.binaryType = 'arraybuffer';
ws.onmessage = message => {
const hasMetadata = Boolean(this.getMetadata());
return parseStreamMessage({
message: message.data,
onResult: this.onXVIZMessage,
onError: this.onError,
debug: this._debug.bind(this, 'parse_message'),
worker: hasMetadata && this.options.worker,
maxConcurrency: this.options.maxConcurrency
});
};
ws.onerror = this.onError;
ws.onclose = event => {
this._onWSClose(event);
reject(event);
};
// On success, resolve the promise with the now ready socket
ws.onopen = () => {
this.socket = ws;
this._onWSOpen();
resolve(ws);
};
} catch (err) {
reject(err);
}
}).catch(event => {
this._onWSError(event);
const isAbnormalClosure = event.code > 1000 && event.code !== 1005;
// Retry if abnormal or connection never established
if (isAbnormalClosure || !this.socket) {
retry();
}
});
}, this.retrySettings).catch(this._onWSError);
}
函數頭上的解釋是:自動嘗試打開一個XVIZ socket連接,返回Promise類型的WebSocket連接
其中用到promise-retry這個包,該包的介紹是:
Retries a function that returns a promise, leveraging the power of the retry module to the promises world.
這是一個非常好的實踐。因為物聯網應用會偶發網絡斷聯,通過設置一定容差的連接嘗試,實現續聯。
其中WebSocket的使用可以參考WebSocket
ws.onmessage = message => {
const hasMetadata = Boolean(this.getMetadata());
return parseStreamMessage({
message: message.data,
onResult: this.onXVIZMessage,
onError: this.onError,
debug: this._debug.bind(this, 'parse_message'),
worker: hasMetadata && this.options.worker,
maxConcurrency: this.options.maxConcurrency
});
};
websocket當收到消息時,首先判斷是否有元數據。然后調用parseStreamMessage來處理StreamMessage。
其定義如下:
parseStreamMessage will parse the data and handle GLB encoded
XVIZ as well as other formats of the data.
XVIZ parsing functions will decode the binary container, parse the JSON and resolve binary
references. The application will get a "patched" JSON structure, with the difference from the basic
JSON protocol format being that certain arrays will be compact typed arrays instead of classic
JavaScript arrays.
If an attribute has been hydrated from binary then it will be transformed into the corresponding
TypeArray. Typed arrays do not support nesting so all numbers will be laid out flat and the
application needs to know how many values represent one element, for instance 3 values represent the
x, y, z
coordinates of a point.
parseXVIZMessage
import {parseXVIZMessage, XVIZ_MESSAGE} from '@xviz/parser';
parseXVIZMessage({
message,
onResult: data => {
switch (data.type) {
case XVIZ_MESSAGE.METADATA: // do something
case XVIZ_MESSAGE.TIMESLICE: // do something
case XVIZ_MESSAGE.INCOMPLETE: // do something
}
},
onError: console.error,
worker: true
maxConcurrency: 4
});
Parameters:
-
opts
(Object)-
message
(Object|String|ArrayBuffer) - XVIZ message to decode. -
onResult
(Function) - callback if the message is parsed successfully. Receives a single
argumentdata
.data.type
is one ofXVIZ_MESSAGE
. -
onError
(Function) - callback if the parser encouters an error. -
debug
(Function) - callback to log debug info. -
worker
(Boolean|String) - use Web Wroker to parse the message. Enabling worker is recommended
to improve loading performance in production. Defaultfalse
.- boolean: whether to use the default worker. Note that callbacks in XVIZ config are ignored by
the default worker. If you need to inject custom hooks into the parsing process, create a
custom worker using streamDataWorker. - string: a custom worker URL to use.
- boolean: whether to use the default worker. Note that callbacks in XVIZ config are ignored by
-
maxConcurrency
(Number) - the max number of workers to use. Has no effect ifworker
is set
tofalse
. Default4
. -
capacity
(Number) - the limit on the number of messages to queue for the workers to process,
has no effect if set otnull
. Defaultnull
.
-
XVIZ_MESSAGE
Enum of stream message types.
METADATA
TIMESLICE
ERROR
INCOMPLETE
-
DONE
這里this.onXVIZMessage在xviz-loader-interface.js定義如下
onXVIZMessage = message => {
switch (message.type) {
case LOG_STREAM_MESSAGE.METADATA:
this._onXVIZMetadata(message);
this.emit('ready', message);
break;
case LOG_STREAM_MESSAGE.TIMESLICE:
this._onXVIZTimeslice(message);
this.emit('update', message);
break;
case LOG_STREAM_MESSAGE.DONE:
this.emit('finish', message);
break;
default:
this.emit('error', message);
}
};
當解析出的消息類型是METADATA則調用內部函數_onXVIZMetadata,并發出ready消息
_onXVIZMetadata(metadata) {
this.set('metadata', metadata);
if (metadata.streams && Object.keys(metadata.streams).length > 0) {
this.set('streamSettings', metadata.streams);
}
if (!this.streamBuffer) {
throw new Error('streamBuffer is missing');
}
this.logSynchronizer = this.logSynchronizer || new StreamSynchronizer(this.streamBuffer);
const timestamp = this.get('timestamp');
const newTimestamp = Number.isFinite(timestamp) ? timestamp : metadata.start_time;
if (Number.isFinite(newTimestamp)) {
this.seek(newTimestamp);
}
}
這里主要設置一些參數,并定義了一個logSynchronizer其為StreamSynchronizer的一個實例。用以獲取實際的stream數據。
StreamSynchronizer
The StreamSynchronizer
class looks into a XVIZStreamBuffer and retrieves the most relevant datum from each stream that "matches" the current timestamp.
當解析出的消息類型是TIMESLICE則調用內部函數_onXVIZTimeslice,并發出update消息
_onXVIZTimeslice(timeslice) {
const oldStreamCount = this.streamBuffer.streamCount;
const bufferUpdated = this.streamBuffer.insert(timeslice);
if (bufferUpdated) {
this._bumpDataVersion();
}
if (getXVIZConfig().DYNAMIC_STREAM_METADATA && this.streamBuffer.streamCount > oldStreamCount) {
const streamsMetadata = {};
const streamSettings = this.get('streamSettings');
for (const streamName in timeslice.streams) {
streamsMetadata[streamName] = timeslice.streams[streamName].__metadata;
// Add new stream name to stream settings (default on)
if (!(streamName in streamSettings)) {
streamSettings[streamName] = true;
}
}
this.set('streamsMetadata', streamsMetadata);
}
return bufferUpdated;
}
其主要作用是將timeslice加入到當前的streamBuffer中,并當timeslice有新的stream且其中的metadata是DYNAMIC_STREAM_METADATA時在streamsMetadata添加該metadata,并更新streamsMetadata
自此一個XVIZLiveLoader實例化完畢并實現connect連接
getCurrentFrame
接下來看看如何獲取當前幀的,getCurrentFrame的源碼可以定位到xviz-loader-interface.js文件
getCurrentFrame = createSelector(
this,
[this.getStreamSettings, this.getCurrentTime, this.getLookAhead, this._getDataVersion],
// `dataVersion` is only needed to trigger recomputation.
// The logSynchronizer has access to the timeslices.
(streamSettings, timestamp, lookAhead) => {
const {logSynchronizer} = this;
if (logSynchronizer && Number.isFinite(timestamp)) {
logSynchronizer.setTime(timestamp);
logSynchronizer.setLookAheadTimeOffset(lookAhead);
return logSynchronizer.getCurrentFrame(streamSettings);
}
return null;
}
);
這里有個很好的實踐——createSelector,其在/modules/src/utils/create-selector.js中定義
import {createSelector} from 'reselect';
// reselect selectors do not update if called with the same arguments
// to support calling them without arguments, pass logLoader version
export default function createLogSelector(logLoader, ...args) {
const selector = createSelector(...args);
return () => selector(logLoader._version);
}
這里使用了一個reselect的庫,
Reselect
Simple “selector” library for Redux (and others) inspired by getters in NuclearJS, subscriptions in re-frame and this proposal from speedskater.
- Selectors can compute derived data, allowing Redux to store the minimal possible state.
- Selectors are efficient. A selector is not recomputed unless one of its arguments changes.
- Selectors are composable. They can be used as input to other selectors.
通過介紹可以看出reselect庫可以精簡state結構,并且抑制不必要的計算。
作者為了防止因參數相同不觸發reselect的重新計算,通過dataVersion來進行重新計算的控制,以達到減少state的更改、節約前端的繪制。
而獲取當前數據幀則通過logSynchronizer.getCurrentFrame(streamSettings)得到。
至此一個完整的獲取當前數據幀的流程講完了,其中有很多細節還需要消化,有一同研究的小伙伴可以交流,歡迎留言!