feat(schema-validation): support validation for layer's options

implement with JSON Schema (ajv)
This commit is contained in:
yuqi.pyq 2019-10-27 20:58:33 +08:00
parent 3e22f21a5c
commit 9c5766d0e3
15 changed files with 362 additions and 49 deletions

View File

@ -0,0 +1,68 @@
# ConfigSchemaValidation 设计
用户在使用 L7 的 Scene/Layer API 时,由于参数配置项众多难免会误传。需要在运行时通过校验提前发现并给出友好的提示。
另外由于 L7 允许用户自定义 Layer 与 LayerPlugin规范化参数配置项也能提升易用性和质量。
这方面 Webpack 做的很好,使用 [schema-utils](https://github.com/webpack/schema-utils) 基于 JSON Schema 对 Plugin 和 Loader 进行校验。如果传入了错误的配置项,会给出友好的提示:
```
Invalid configuration object. MyPlugin has been initialised using a configuration object that does not match the API schema.
- configuration.optionName should be a integer.
```
和 Webpack 一样,我们也选择 [ajv](https://github.com/epoberezkin/ajv) 作为 JSON Schema 校验器。
目前我们只在 Layer 初始阶段进行校验,一旦校验失败会中断后续初始化插件的处理,并在控制台给出校验失败信息。后续需要在属性更新时同样进行校验。
## Layer 基类配置项 Schema
目前在基类中我们声明了如下属性及其对应的校验规则:
```javascript
export default {
properties: {
// 开启拾取
enablePicking: {
type: 'boolean',
},
// 开启高亮
enableHighlight: {
type: 'boolean',
},
// 高亮颜色:例如 [0, 0, 1, 1] 或者 '#ffffff'
highlightColor: {
oneOf: [
{
type: 'array',
items: {
type: 'number',
minimum: 0,
maximum: 1,
},
},
{
type: 'string',
},
],
},
},
};
```
如果传入了错误的配置项则会在控制台给出提示。
## Layer 子类配置项 Schema
Layer 子类可以通过重载 `getConfigSchema()` 方法定义自身的特有属性。例如 `PolygonLayer` 需要定义透明度:
```javascript
protected getConfigSchema() {
return {
properties: {
opacity: {
type: 'number',
minimum: 0,
maximum: 1,
},
},
};
}
```

View File

@ -19,18 +19,20 @@
"author": "xiaoiver",
"license": "ISC",
"dependencies": {
"@mapbox/tiny-sdf": "^1.1.1",
"eventemitter3": "^3.1.0",
"@l7/utils": "0.0.1",
"@mapbox/tiny-sdf": "^1.1.1",
"ajv": "^6.10.2",
"eventemitter3": "^3.1.0",
"gl-matrix": "^3.1.0",
"hammerjs": "^2.0.8",
"inversify": "^5.0.1",
"inversify-inject-decorators": "^3.1.0",
"lodash": "^4.17.15",
"mapbox-gl": "^1.2.1",
"merge-json-schemas": "^1.0.0",
"probe.gl": "^3.1.1",
"reflect-metadata": "^0.1.13",
"tapable": "^2.0.0-beta.8",
"mapbox-gl": "^1.2.1",
"viewport-mercator-project": "^6.2.1"
},
"devDependencies": {

View File

@ -1,4 +1,5 @@
import { inject, injectable } from 'inversify';
import Ajv from 'ajv';
import { injectable } from 'inversify';
import { IGlobalConfig, IGlobalConfigService } from './IConfigService';
const defaultGlobalConfig: Partial<IGlobalConfig> = {
@ -26,10 +27,23 @@ const defaultGlobalConfig: Partial<IGlobalConfig> = {
scales: {},
};
// @see https://github.com/epoberezkin/ajv#options
const ajv = new Ajv({
allErrors: true,
verbose: true,
});
@injectable()
export default class GlobalConfigService implements IGlobalConfigService {
private config: Partial<IGlobalConfig> = defaultGlobalConfig;
/**
* Layer
*/
private layerConfigValidatorCache: {
[layerName: string]: Ajv.ValidateFunction;
} = {};
public getConfig() {
return this.config;
}
@ -47,4 +61,29 @@ export default class GlobalConfigService implements IGlobalConfigService {
public reset() {
this.config = defaultGlobalConfig;
}
public registerLayerConfigSchemaValidator(layerName: string, schema: object) {
if (!this.layerConfigValidatorCache[layerName]) {
this.layerConfigValidatorCache[layerName] = ajv.compile(schema);
}
}
public validateLayerConfig(layerName: string, data: object) {
const validate = this.layerConfigValidatorCache[layerName];
if (validate) {
const valid = validate(data);
if (!valid) {
return {
valid,
errors: validate.errors,
errorText: ajv.errorsText(validate.errors),
};
}
}
return {
valid: true,
errors: null,
errorText: null,
};
}
}

View File

@ -1,3 +1,4 @@
import Ajv from 'ajv';
import { ILayerGlobalConfig } from '../layer/ILayerService';
import { IMapConfig } from '../map/IMapService';
import { IRenderConfig } from '../renderer/IRendererService';
@ -8,4 +9,13 @@ export interface IGlobalConfigService {
getConfig(): Partial<IGlobalConfig>;
setAndCheckConfig(config: Partial<IGlobalConfig>): boolean;
reset(): void;
registerLayerConfigSchemaValidator(layerName: string, schema: object): void;
validateLayerConfig(
layerName: string,
data: object,
): {
valid: boolean;
errors: Ajv.ErrorObject[] | null | undefined;
errorText: string | null;
};
}

View File

@ -0,0 +1,57 @@
import 'reflect-metadata';
import ConfigService from '../ConfigService';
import { IGlobalConfigService } from '../IConfigService';
describe('ConfigService', () => {
let configService: IGlobalConfigService;
beforeEach(() => {
configService = new ConfigService();
});
it("should validate layer's options according to JSON schema", () => {
configService.registerLayerConfigSchemaValidator('testLayer', {
properties: {
opacity: {
type: 'number',
minimum: 0,
maximum: 1,
},
enablePicking: {
type: 'boolean',
},
},
});
const { valid, errorText } = configService.validateLayerConfig(
'testLayer',
{ opacity: 'invalid' },
);
expect(valid).toBeFalsy();
expect(errorText).toMatch('opacity should be number');
expect(
configService.validateLayerConfig('testLayer', {
opacity: 1.5,
}).valid,
).toBeFalsy();
expect(
configService.validateLayerConfig('testLayer', {
enablePicking: 1.5,
}).valid,
).toBeFalsy();
expect(
configService.validateLayerConfig('testLayer', {
opacity: 1.0,
}).valid,
).toBeTruthy();
expect(
configService.validateLayerConfig('testLayer', {
opacity: 0.0,
}).valid,
).toBeTruthy();
});
});

View File

@ -1,4 +1,4 @@
import { AsyncParallelHook, SyncHook } from 'tapable';
import { SyncBailHook, SyncHook } from 'tapable';
import { IModel } from '../renderer/IModel';
import { IMultiPassRenderer } from '../renderer/IMultiPassRenderer';
import { ISource, ISourceCFG } from '../source/ISourceService';
@ -31,19 +31,17 @@ export interface ILayer {
name: string; // 代表 Layer 的类型
// visible: boolean;
// zIndex: number;
// type: string;
// id: number;
plugins: ILayerPlugin[];
hooks: {
init: SyncHook<unknown>;
beforeRender: SyncHook<unknown>;
afterRender: SyncHook<unknown>;
beforePickingEncode: SyncHook<unknown>;
afterPickingEncode: SyncHook<unknown>;
beforeHighlight: SyncHook<unknown>;
afterHighlight: SyncHook<unknown>;
beforeDestroy: SyncHook<unknown>;
afterDestroy: SyncHook<unknown>;
init: SyncBailHook<void, boolean | void>;
beforeRender: SyncBailHook<void, boolean | void>;
afterRender: SyncHook<void>;
beforePickingEncode: SyncHook<void>;
afterPickingEncode: SyncHook<void>;
beforeHighlight: SyncHook<[number[]]>;
afterHighlight: SyncHook<void>;
beforeDestroy: SyncHook<void>;
afterDestroy: SyncHook<void>;
};
models: IModel[];
sourceOption: {
@ -72,6 +70,10 @@ export interface ILayer {
setEncodedData(encodedData: IEncodeFeature[]): void;
getEncodedData(): IEncodeFeature[];
getStyleOptions(): Partial<ILayerInitializationOptions>;
/**
* JSON Schema
*/
getConfigSchemaForValidation(): object;
isDirty(): boolean;
/**
* 使

View File

@ -35,9 +35,9 @@ export default class LayerService implements ILayerService {
// .filter((layer) => layer.isDirty())
.forEach((layer) => {
// trigger hooks
layer.hooks.beforeRender.call(layer);
layer.hooks.beforeRender.call();
layer.render();
layer.hooks.afterRender.call(layer);
layer.hooks.afterRender.call();
});
}

View File

@ -1,11 +1,11 @@
import { inject, injectable } from 'inversify';
import { injectable } from 'inversify';
import { lazyInject } from '../../../index';
import { TYPES } from '../../../types';
import {
IInteractionService,
InteractionEvent,
} from '../../interaction/IInteractionService';
import { ILayer, ILayerService } from '../../layer/ILayerService';
import { ILayer } from '../../layer/ILayerService';
import { ILogService } from '../../log/ILogService';
import { gl } from '../gl';
import { IFramebuffer } from '../IFramebuffer';
@ -104,9 +104,9 @@ export default class PixelPickingPass implements IPass {
const originRenderFlag = this.layer.multiPassRenderer.getRenderFlag();
this.layer.multiPassRenderer.setRenderFlag(false);
// trigger hooks
layer.hooks.beforeRender.call(layer);
layer.hooks.beforeRender.call();
layer.render();
layer.hooks.afterRender.call(layer);
layer.hooks.afterRender.call();
this.layer.multiPassRenderer.setRenderFlag(originRenderFlag);
this.alreadyInRendering = false;
@ -145,8 +145,6 @@ export default class PixelPickingPass implements IPass {
framebuffer: this.pickingFBO,
});
this.logger.info('try to picking');
if (
pickedColors[0] !== 0 ||
pickedColors[1] !== 0 ||
@ -213,12 +211,11 @@ export default class PixelPickingPass implements IPass {
// TODO: highlight pass 需要 multipass
const originRenderFlag = this.layer.multiPassRenderer.getRenderFlag();
this.layer.multiPassRenderer.setRenderFlag(false);
this.layer.hooks.beforeRender.call(this.layer);
// @ts-ignore
this.layer.hooks.beforeHighlight.call(this.layer, [r, g, b]);
this.layer.hooks.beforeRender.call();
this.layer.hooks.beforeHighlight.call([r, g, b]);
this.layer.render();
this.layer.hooks.afterHighlight.call(this.layer);
this.layer.hooks.afterRender.call(this.layer);
this.layer.hooks.afterHighlight.call();
this.layer.hooks.afterRender.call();
this.layer.multiPassRenderer.setRenderFlag(originRenderFlag);
}
}

View File

@ -10,7 +10,6 @@ import {
IMapService,
IModel,
IMultiPassRenderer,
InteractionEvent,
IRendererService,
IShaderModuleService,
ISourceCFG,
@ -24,7 +23,10 @@ import {
} from '@l7/core';
import Source from '@l7/source';
import { isFunction } from 'lodash';
import { SyncHook } from 'tapable';
// @ts-ignore
import mergeJsonSchemas from 'merge-json-schemas';
import { SyncBailHook, SyncHook } from 'tapable';
import ConfigSchemaValidationPlugin from '../plugins/ConfigSchemaValidationPlugin';
import DataMappingPlugin from '../plugins/DataMappingPlugin';
import DataSourcePlugin from '../plugins/DataSourcePlugin';
import FeatureScalePlugin from '../plugins/FeatureScalePlugin';
@ -33,6 +35,7 @@ import PixelPickingPlugin from '../plugins/PixelPickingPlugin';
import RegisterStyleAttributePlugin from '../plugins/RegisterStyleAttributePlugin';
import ShaderUniformPlugin from '../plugins/ShaderUniformPlugin';
import UpdateStyleAttributePlugin from '../plugins/UpdateStyleAttributePlugin';
import baseLayerSchema from './schema';
export interface ILayerModelInitializationOptions {
moduleName: string;
@ -64,16 +67,15 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
// 生命周期钩子
public hooks = {
init: new SyncHook(['layer']),
beforeRender: new SyncHook(['layer']),
afterRender: new SyncHook(['layer']),
beforePickingEncode: new SyncHook(['layer']),
afterPickingEncode: new SyncHook(['layer']),
// @ts-ignore
beforeHighlight: new SyncHook(['layer', 'pickedColor']),
afterHighlight: new SyncHook(['layer']),
beforeDestroy: new SyncHook(['layer']),
afterDestroy: new SyncHook(['layer']),
init: new SyncBailHook<void, boolean | void>(),
beforeRender: new SyncBailHook<void, boolean | void>(),
afterRender: new SyncHook<void>(),
beforePickingEncode: new SyncHook<void>(),
afterPickingEncode: new SyncHook<void>(),
beforeHighlight: new SyncHook<[number[]]>(['pickedColor']),
afterHighlight: new SyncHook<void>(),
beforeDestroy: new SyncHook<void>(),
afterDestroy: new SyncHook<void>(),
};
// 待渲染 model 列表
@ -84,6 +86,14 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
// 插件集
public plugins: ILayerPlugin[] = [
/**
*
* @see /dev-docs/ConfigSchemaValidation.md
*/
new ConfigSchemaValidationPlugin(),
/**
* Source
*/
new DataSourcePlugin(),
/**
* StyleAttribute VertexAttribute
@ -132,6 +142,8 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
private encodedData: IEncodeFeature[];
private configSchema: object;
/**
*
*/
@ -174,7 +186,7 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
}
public init() {
this.hooks.init.call(this);
this.hooks.init.call();
this.buildModels();
return this;
}
@ -244,14 +256,14 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
}
public destroy() {
this.hooks.beforeDestroy.call(this);
this.hooks.beforeDestroy.call();
// 清除所有属性以及关联的 vao
this.styleAttributeService.clearAllAttributes();
// 销毁所有 model
this.models.forEach((model) => model.destroy());
this.hooks.afterDestroy.call(this);
this.hooks.afterDestroy.call();
}
public isDirty() {
@ -283,6 +295,18 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
return this.encodedData;
}
public getConfigSchemaForValidation() {
if (!this.configSchema) {
// 相比 allOf, merge 有一些优势
// @see https://github.com/goodeggs/merge-json-schemas
this.configSchema = mergeJsonSchemas([
baseLayerSchema,
this.getConfigSchema(),
]);
}
return this.configSchema;
}
public pick({ x, y }: { x: number; y: number }) {
this.interactionService.triggerHover({ x, y });
}
@ -313,6 +337,10 @@ export default class BaseLayer<ChildLayerStyleOptions = {}> implements ILayer {
});
}
protected getConfigSchema() {
throw new Error('Method not implemented.');
}
protected buildModels() {
throw new Error('Method not implemented.');
}

View File

@ -0,0 +1,28 @@
/**
* BaseLayer Schema
*/
export default {
properties: {
enablePicking: {
type: 'boolean',
},
enableHighlight: {
type: 'boolean',
},
highlightColor: {
oneOf: [
{
type: 'array',
items: {
type: 'number',
minimum: 0,
maximum: 1,
},
},
{
type: 'string',
},
],
},
},
};

View File

@ -0,0 +1,42 @@
import {
IGlobalConfigService,
ILayer,
ILayerPlugin,
ILogService,
lazyInject,
TYPES,
} from '@l7/core';
/**
* Layer
*/
export default class ConfigSchemaValidationPlugin implements ILayerPlugin {
@lazyInject(TYPES.IGlobalConfigService)
private readonly configService: IGlobalConfigService;
@lazyInject(TYPES.ILogService)
private readonly logger: ILogService;
public apply(layer: ILayer) {
layer.hooks.init.tap('ConfigSchemaValidationPlugin', () => {
this.configService.registerLayerConfigSchemaValidator(
layer.name,
layer.getConfigSchemaForValidation(),
);
const { valid, errorText } = this.configService.validateLayerConfig(
layer.name,
layer.getStyleOptions(),
);
if (!valid) {
this.logger.error(errorText || '');
// 中断 init 过程
return false;
}
});
layer.hooks.beforeRender.tap('ConfigSchemaValidationPlugin', () => {
// TODO: 配置项发生变化,需要重新校验
});
}
}

View File

@ -71,8 +71,7 @@ export default class PixelPickingPlugin implements ILayerPlugin {
layer.hooks.beforeHighlight.tap(
'PixelPickingPlugin',
// @ts-ignore
(l: unknown, pickedColor: unknown) => {
(pickedColor: number[]) => {
const { highlightColor } = layer.getStyleOptions();
const highlightColorInArray =
typeof highlightColor === 'string'
@ -81,7 +80,7 @@ export default class PixelPickingPlugin implements ILayerPlugin {
layer.models.forEach((model) =>
model.addUniforms({
u_PickingStage: PickingStage.HIGHLIGHT,
u_PickingColor: pickedColor as number[],
u_PickingColor: pickedColor,
u_HighlightColor: highlightColorInArray.map((c) => c * 255),
}),
);

View File

@ -23,6 +23,18 @@ export function polygonTriangulation(feature: IEncodeFeature) {
export default class PolygonLayer extends BaseLayer<IPolygonLayerStyleOptions> {
public name: string = 'PolygonLayer';
protected getConfigSchema() {
return {
properties: {
opacity: {
type: 'number',
minimum: 0,
maximum: 1,
},
},
};
}
protected renderModels() {
const { opacity } = this.getStyleOptions();
this.models.forEach((model) =>

View File

@ -85,13 +85,11 @@ export default class AdvancedAPI extends React.Component {
.add(styleOptions, 'pickingX', 0, window.innerWidth)
.onChange((pickingX: number) => {
layer.pick({ x: pickingX, y: styleOptions.pickingY });
// scene.render();
});
pointFolder
.add(styleOptions, 'pickingY', 0, window.innerHeight)
.onChange((pickingY: number) => {
layer.pick({ x: styleOptions.pickingX, y: pickingY });
// scene.render();
});
pointFolder
.addColor(styleOptions, 'highlightColor')

View File

@ -9618,6 +9618,11 @@ lodash.get@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
lodash.isarray@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-4.0.0.tgz#2aca496b28c4ca6d726715313590c02e6ea34403"
integrity sha1-KspJayjEym1yZxUxNZDALm6jRAM=
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
@ -9628,6 +9633,16 @@ lodash.ismatch@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37"
integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc=
lodash.isnil@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c"
integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw=
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.map@^4.5.1:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3"
@ -9638,6 +9653,11 @@ lodash.memoize@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
lodash.mergewith@^4.6.0:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
lodash.set@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@ -10070,6 +10090,17 @@ merge-descriptors@1.0.1:
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
merge-json-schemas@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/merge-json-schemas/-/merge-json-schemas-1.0.0.tgz#2d635eaa8401c5fa3d03f30f89349fc7cafee62f"
integrity sha1-LWNeqoQBxfo9A/MPiTSfx8r+5i8=
dependencies:
lodash.isarray "^4.0.0"
lodash.isnil "^4.0.0"
lodash.isplainobject "^4.0.6"
lodash.mergewith "^4.6.0"
lodash.uniq "^4.5.0"
merge-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"