There are not too many skills and experience in this article. What is recorded is the process of an idea from birth to realization

Background demand

In my last article on several suggestions for building large Mobx applications, I mentioned using schema to contract data structures. Unfortunately, on the browser side, I couldn’t find a suitable Schmea library, so I had to use Record in Immutable.

If you don’t know what a schema is, it’s a simple explanation: Messages are used to communicate between different components within an application, and between applications and servers. As applications grow in complexity, the data structures of messages become complex and large. Defining the schema for each type of message or object that needs to be used in advance helps to ensure the correctness of communication and prevent the passing in of non-existent fields or incorrect types of fields. It also acts as a self-explanatory document for future maintenance. Let’s take the JOI class library as an example

const Joi = require('joi');

const schema = Joi.object().keys({
    username: Joi.string().alphanum().min(3).max(30).required(),
    password: Joi.string().regex(/ ^ [a zA - Z0-9] {30} 3 $/),
    access_token: [Joi.string(), Joi.number()],
    birthyear: Joi.number().integer().min(1900).max(2013),
    email: Joi.string().email({ minDomainAtoms: 2 })
}).with('username'.'birthyear').without('password'.'access_token');

// Return result.
const result = Joi.validate({ username: 'abc'.birthyear: 1994 }, schema);
// result.error === null -> valid

// You can also pass a callback which will be called synchronously with the validation result.
Joi.validate({ username: 'abc'.birthyear: 1994 }, schema, function (err, value) {});// err === null -> valid
Copy the code

Like all the Schema libraries you can find on NPM, they have always adopted a “post-validation” mechanism, in which the schema is defined in advance and then the objects that need to be validated are handed to the Schema for validation, which I am not satisfied with. I prefer the Reacord approach:

const Person = Record({
  name: ' '.age: ' '
})
const person = new Person({
  name: 'Lee'.age: 22,})const team = new List(jsonData).map(Person) // => List<Person>
Copy the code

In the example above, Schema has a “class” -like feature that you can use to create instances of specified data structures. An error will be reported if you pass in attributes that are not predefined when creating an instance. The catch, however, is that Record does not support further constraints on each field: specifying type, maximum and minimum values, and so on, as seen in JOI.

Since we can’t find a satisfactory schema class library, we might as well write one ourselves. To sum up, it needs to have the following two abilities:

  • The ability to create instances against schemas, rather than post-facto validation
  • Supports field constraints in schema definition

Design of the API

Before development, we need to think about and agree on how it will be used in the future. A preliminary conclusion has been drawn on this point in the previous section.

Assume that the library name is Schema

  • Create Schema:
const PersonSchema = Schema({
  name: ' '.age: ' '
})
Copy the code

We do support field constraints, but you don’t need them. In this case, only the schema field names and default values are agreed

  • Instantiation Schema:
const person = PersonSchema({
  name: 'Lee'.age: 22
})
Copy the code
  • Constrain fields:
const PersonSchema = Schema({
  name: Types().string().default(' ').required(),
  age: Types().number().required()
})
Copy the code

Explain, ideally you should use the React of PropTypes way to constraint of fields, such as PropTypes. Func. IsRequired, but how can achieve, then provide class Types for curve in the form of chain called salvation, can constraint conditions is as follows:

  • Data type constraint
    • string(): String only
    • number(): Indicates the numeric type only
    • boolean(): Boolean type only
    • array(): Array type only
    • object(): Object type only
  • Other constraints
    • required(): This field is mandatory for instance creation
    • default(value): The default value of this field
    • valueof(value1, value2, value3): The value must be one of value1, value2, or value3

Of course, you can also add other kinds of constraints, such as min(), Max (), regex(), etc., which can be implemented in the second phase. These are the most important for now

  • Schema nesting is supported
const PersonSchema = Schema({
  name: Types().string().default(' ').required(),
  age: Types().number().required(),
  job: Schema({
    title: ' '.company: ' '})})Copy the code

implementation

Types

What does the chain-type call to Types().string().required() remind me of? How does jQuery implement chain calls? The end of the function call always returns a reference to jQuery.

Types is a class, and Types() is used to generate an instance. You may have noticed that you didn’t use the keyword new because I think it’s too cumbersome to use the keyword new. It’s also technically easy to generate instances without using the new keyword, as long as 1) you define classes using functions instead of classes; 2) Add an instance judgment to the constructor:

function Types() {
  if(! (this instanceof Types)) {
    return newTypes(); }}Copy the code

As for the validation of various data types, we use and encapsulate loDash methods to implement. Each time the user executes a constraint (.string()) function, we generate an internal validation function stored in the Validators variable of the Types instance for future validation of that field value

import _ from 'lodash'

const lodashWrap = fn= > {
  return value= > {
    return fn.call(this, value);
  };
};

function Types() {
  if(! (this instanceof Types)) {
    return new Types();
  }
  this.validators = []
}

Types.prototype = {
  string: function() {
    this.validators.push(lodashWrap(_.isString));
    return this;
  },
Copy the code

Similarly, we implement default, Required, and Valueof


function Types() {
  if(! (this instanceof Types)) {
    return new Types();
  }
  this.validators = [];
  this.isRequired = false;
  this.defaultValue = void 0;
  this.possibleValues = [];
}


Types.prototype = {
  default: function(defaultValue) {
    this.defaultValue = defaultValue;
    return this;
  },
  required: function() {
    this.isRequired = true;
    return this;
  },
  valueOf: function() {
    this.possibleValues = _.flattenDeep(Array.from(arguments));
    return this
Copy the code

Schema

From the usage of Schema() we agreed on earlier, it is not difficult to determine the basic structure of the Schema as follows:

export const Schema = definition= > {
  return function(inputObj = {}) {
    return{}}}Copy the code

Schema code implementation for the most part is nothing fancy, basically iterating through definitions to get various constraint information for different fields:

export const Schema = definition= > {
  const fieldValidator = {};
  const fieldDefaults = {};
  const fieldPossibleValues = {};
  const fieldSchemas = {};
Copy the code

Fieldvalidators and fieldDefaults in the above code are “dictionaries” that categorize the various constraints that store different fields

In definition we get the definition of the schema, which is the constraint on each key. Through various judgments of field values, constraint information can be obtained for the expression:

  • If the value is notTypes, indicating that the user defined the field but did not constrain it, and that the current value is the default. No validation is required when creating an instance or writing to it
  • If the value isTypesInstance, so we can get all kinds of constraint information from the properties of the instance, as beforeTypesMeaning in the definitionvalidators,defaultValue,isRequired,possibleValues
  • If the value is a function, it indicates that the user defines a nested Schema and the defined Schema is used for verification

Undertake the above code:

const fields = Object.keys(definition);
fields.forEach(field= > {
  const fieldValue = definition[field];
  if (_.isFunction(fieldValue)) {
    fieldSchemas[field] = fieldValue;
    return;
  }
  if(! (fieldValueinstanceof Types)) {
    fieldDefaults[field] = fieldValue;
    return;
  }
  if (fieldValue.validators.length) {
    fieldValidator[field] = fieldValue.validators;
  }
  if (typeoffieldValue.defaultValue ! = ="undefined") {
    fieldDefaults[field] = fieldValue.defaultValue;
  }
  if(fieldValue.possibleValues && fieldValue.possibleValues.length) { fieldPossibleValues[field] = fieldValue.possibleValues; }});Copy the code

The key to the implementation of the Schema class is how to implement the SET accessor, that is, how to verify when the user assigns a value to a field, and allow the assignment to succeed only after the verification passes. There are two options for how to implement accessors:

  • useObject.definePropertyDefines accessors for objects
  • Using Proxy mechanism

The essence of object.defineProperty is to modify an Object (of course you can make a deep copy of the original Object and then modify it to avoid contamination); In terms of semantics, Proxy is more suitable for this scenario, and there is no pollution problem. And after trying both solutions at the same time, the cost of using Proxy is lower. We decided to use the Proxy mechanism, and the code structure looks like this:

export const Schema = definition= > {
  return function(inputObj = {}) {
    const proxyHandler = {
      get: (target, prop) = > {
        return target[prop];
      },
      set: (target, prop, value) = > {
        // LOTS OF TODO}}return new Proxy(Object.assign({}, inputObj), proxyHandler); }}Copy the code

What is omitted from the set method is the step-by-step judgment code

conclusion

The source of this article can be found at github.com/hh54188/sch…

You can copy it, play with it, test it, modify it. But don’t use it in a production environment, it hasn’t been fully tested, and there are many details and boundary cases to deal with

More suggestions are welcome through Pull Request and Issues


This article is also published in my zhihu front column, welcome your attention