1.9 参数校验(上)

134 阅读6分钟

在浏览器打开http://localhost:8000/user,看下现在的数据。

我先把数据清掉。

找段代码插入一句:localStorage.clear()就能清了。服务启动一次后记得删了。也可以加个接口来做清理工作。

打开浏览器F12,执行以下代码:

fetch("/user", {
  method: "post",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ aa: "haha" }),
});

刷新页面:

image.png

是不是插入了一条不应该插入的数据?这就属于程序漏洞。严重的可能会搞垮你的服务器。

优化思路

怎么解决这个问题呢?其实接口流动的每个环节都可以处理,上层控制层(user.route.ts)、中间业务处理层(user.service.ts)、底层数据访问层(model.ts)都能做,只是在哪个环节合适的问题。

针对用户页面多传递的参数这种情况,底层model.ts比较合适,因为多传的参数可能在前面环节有特殊的作用(比如只是一个判断分支的标志),如果不想每个service都辛苦,就在底层处理好。

但model中怎么校验呢?上文看到,我们Model类的结构是这样的:

export class Model<T> {}

这个T,只是TypeScript中的一个泛型,只是开发阶段TypeScript的一个约束而已。

再次重申,一定要注意Deno并不是真正的TypeScript运行时,本质仍是依赖V8引擎运行的JavaScript。 所以TypeScript的特性(type、interface、泛型、private声明)等,在编译成JavaScript后就都没了,只有enum枚举会编译为一个对象。

在model中,我们获取不到这个泛型T的任何信息,因为它就是个虚拟的东西,假的。

这个泛型T,传递的是什么,你还有印象吗?是个接口:

export interface User {
  id: string;
  author: string;
  age: number;
}

userModel: Model<User>;

为了让Model知道User的属性信息,必须把它改造成class:

export class User {
  id: string;
  author: string;
  age: number;
}

再把它当作参数传递给Model:

this.userModel = new Model("users", User);

但你会悲哀地发现,class是真实存在的,但它的属性id、author、age一个个都是假的,class编译后只剩下光杆司令了。

export class User {}

类属性initializer

甚至当你敲了刚才的代码后,会发现编辑器也开始欺负你,居然报错了:

image.png

你鼠标移上去看报错信息:

Property 'id' has no initializer and is not definitely assigned in the constructor.deno-ts(2564)

这是一条deno默认配置的TypeScript的规则,意思是你不能定义一个空类,你得为这些属性负责,得初始化。这个当然没毛病。但对我们这个需求来说就不太友好了,这时你需要修改deno.jsonc文件,添加一段:

{
  "compilerOptions": {
    "strictPropertyInitialization": false
  }
}

再看下编辑器,是不是波浪线没有了?(如果还不行,可能需要重启下vscode)。

开启装饰器

少年,不要高兴太早,你只是解决了编辑器对你的恶意,怎么才能拿到这几个定义的属性呢? 如果你好好看了《开发前准备》的前两个章节,对ES6中的装饰器应该有些印象(还不记得去看es6.ruanyifeng.com/#docs/decor…www.typescriptlang.org/docs/handbo…)。

它与Java中的注解比较相似,能极大地简化我们的代码。

要使用装饰器和元数据,还得在刚才的deno.jsonc再添加两个属性emitDecoratorMetadataexperimentalDecorators(Deno 1.25版本开始内置了后者,不需要再添加了):

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "strictPropertyInitialization": false
  }
}

再在deps.ts中,引用:

export { Reflect } from "https://deno.land/x/deno_reflect@v0.2.1/mod.ts";

对Deno如何使用,详见deno_reflect文档

Prop装饰器

我们要实现一个Prop装饰器,作用在具体属性上,用法如下:

export class User {
  @Prop()
  id: string;

  @Prop()
  author: string;

  @Prop()
  age: number;
}

意思是通过装饰器,注入给User这个class一些信息。

给个简单的例子,可以放在src/main.ts上面自己看下:

const map: Record<string, any> = {};
function Prop() {
  return function (target: any, propertyKey: string) {
    console.log(target, target.constructor === User, propertyKey);
    // target是类的实例,propertyKey就是author/age
    map[propertyKey] = true;
    return target;
  };
}

class User {
  @Prop()
  author: string;

  @Prop()
  age: string;
}

console.log(map); // { author: true, age: true }

思路就是存储这个User构造函数与这些属性值的关系,这样Model在拿到User之后就能取到这些属性信息了,再对应地进行下过滤。

测试完上面代码就可以删了。

真正开发时,我们可以使用deno.land/x/deno_refl…的反射来简化我们的代码。

优化代码

schema.ts

新建一个src/schema.ts,内容如下:

// deno-lint-ignore-file no-explicit-any
import { Reflect } from "../deps.ts";
export type TargetInstance = any;
export type Constructor = new (...args: any[]) => any;
export type Target = Constructor & Record<string, any>;

const PROP_META_KEY = Symbol("design:prop");

export function Prop(props: any = {}) {
  return function (target: TargetInstance, propertyKey: string) {
    addSchemaMetadata(target, propertyKey, props);
    return target;
  };
}

export function addSchemaMetadata(
  target: TargetInstance,
  propertyKey: string,
  props: any = {},
) {
  Reflect.defineMetadata(PROP_META_KEY, props, target, propertyKey);
}

const schemaPropsCaches = new Map();
const instanceCache = new Map();

export function getInstance(cls: Target) {
  if (instanceCache.has(cls)) {
    return instanceCache.get(cls);
  }
  const instance = new cls();
  instanceCache.set(cls, instance);
  return instance;
}

export function getSchemaMetadata(target: Target): Record<string, any>;
export function getSchemaMetadata<T = any>(
  target: Target,
  propertyKey?: string,
): T;
export function getSchemaMetadata(
  target: Target,
  propertyKey?: string,
) {
  const instance = getInstance(target);
  if (propertyKey) {
    return Reflect.getMetadata(PROP_META_KEY, instance, propertyKey);
  }
  let map: Record<string, any> = schemaPropsCaches.get(target);
  if (!map) {
    map = {};
    Object.keys(instance).forEach((key) => {
      const meta = Reflect.getMetadata(PROP_META_KEY, instance, key);
      if (meta !== undefined) {
        map[key] = meta;
      }
    });
    schemaPropsCaches.set(target, map);
  }
  return map;
}

getSchemaMetadata写的比较复杂,原因是可以存储Prop的具体配置,比如{required:true}之类信息,以备后用。

user.schema.ts

新建src/user/user.schema.ts:

import { Prop } from "../schema.ts";

export class User {
  @Prop()
  author: string;
  
  @Prop()
  age: number;
}

看得出来,我刻意去掉了id属性。为什么呢?因为id是个最基本的属性,但它的类型是字符串、数字或者是某种具体的类,上层开发者不需要知道。所以id的属性应该由model返回给service。

model.ts

对Model进行修改,这里只写变化的部分:

import { Constructor, getSchemaMetadata } from "./schema.ts";

export type ModelWithId<T> = T & { id: string };

export class Model<T extends object, U = ModelWithId<T>> {
  name: string;
  schema: Constructor | undefined;
  constructor(name: string, schema?: Constructor) {
    this.name = name;
    this.schema = schema;
  }

  /** 增加一个文档 */
  async insertOne(doc: T): Promise<string> {
    const id = nanoid();
    const newdoc = { id, ...doc };
    if (this.schema) {
      const meta = getSchemaMetadata(this.schema);
      Object.keys(newdoc).forEach((key) => {
        if (!meta[key] && key !== "id") {
          Reflect.deleteProperty(newdoc, key);
        }
      });
    }
    setData(id, newdoc);
    this.addToIds(id);
    return id;
  }
}

image.png

user.service.ts

仍是只列修改的部分:

import { User } from "./user.schema.ts";

class UserService {
  userModel: Model<User>;
  constructor() {
    this.userModel = new Model("users", User); // 把User当参数传递过去
  }

  async getAll() { // 去掉了返回类型,靠编辑器推断就可以
    return this.userModel.findAll();
  }

  async addUser(user: User) { // User不需要再Omit去掉id
    return this.userModel.insertOne(user);
  }

  async updateUser(id: string, user: Partial<User>) { // 与addUser一样
    return this.userModel.findByIdAndUpdate(id, user);
  }
}

验证

重新在浏览器F12里插入一条数据

fetch("/user", {
  method: "post",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ aa: "haha" }),
});

看到是不是只增加了一条只有id的数据? image.png

作业

你可能对上面的结果有些疑惑,这种情况是不是应该添加失败啊?

是的。

我们可以给Prop加个参数:

export interface SchemaType {
  /**
   * Adds a required validator to this SchemaType
   */
  required?: boolean | [required: boolean, errorMsg: string];
}
export function Prop(props: SchemaType = {}) {
  return function (target: TargetInstance, propertyKey: string) {
    addSchemaMetadata(target, propertyKey, props);
    return target;
  };
}

怎么校验就交给你了!(下面章节我不准备处理了)