streetscape.gl學習筆記(四)

這篇筆記想寫很久了,但是作者最近人生大事不少,耽擱了一陣子。本篇不續接上篇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_STREAMINGIS_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. Default null.
    • endOffset (Number) - offset in seconds. if provided, will not keep timeslices later than
      currentTime + endOffset. Default null.
    • maxLength (Number) - length in seconds. if provided, the buffer will be forced to be no
      bigger than the specified length. Default null.

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
      argument data. data.type is one of XVIZ_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. Default false.
      • 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.
    • maxConcurrency (Number) - the max number of workers to use. Has no effect if worker is set
      to false. Default 4.
    • capacity (Number) - the limit on the number of messages to queue for the workers to process,
      has no effect if set ot null. Default null.
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)得到。
    至此一個完整的獲取當前數據幀的流程講完了,其中有很多細節還需要消化,有一同研究的小伙伴可以交流,歡迎留言!
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。