JavaScript元编程——基于Proxy实现active_record动态查找

1. 元编程

在网络上无意间看到《JavaScript权威指南》第七版的目录,除了NodeJS外,很意外的看到有一个章节叫元编程。

第一次听说元编程这一概念还是来自于Ruby,《Ruby元编程》这本书,很遗憾的是这本书我只看了一点点……对于元编程,我所掌握的也就只有Open Classmethod_missing而已了,不过本文也就只是使用了这么点简单的内容。

1.1 Open Class

在很多面向对象的语言里是无法修改一个类的,但在Ruby中如下代码是合法的:

class Book
  attr_accessor :name

  def initialize(name)
    @name = name
  end

  def to_s
    "书名:#{@name}"
  end

end

book = Book.new("《Ruby 元编程》")

puts book.to_s

# Open Class
class Book

  def pure_name
    @name[0] == "《" && @name[-1] == "》" ? @name[1..-2] : @name
  end

end

puts book.pure_name

虽然重复定义了Book类,但后定义的pure_name方法被“加入”到了原有的类定义中。通过这种方式我们可以在任意位置对我们的代码进行扩展,这一技巧被称为Monkey Patch,以下是一个更实用一点的例子,我们打开了Array类。

# 通过 Open Class 为数组添加一个用于求平均值的方法
arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

class Array

  def average
    sum / size
  end

end

puts arr.average # 输出 5

除了扩展方法外,我们还可以通过这种手段使程序更具有表现力:

arr = [...]

arr.first # 等同于 arr[0]
arr.second # 等同于 arr[1]
arr.last # 等同于 arr[-1]

不过这种手段也容易带来问题,例如打开类以后覆盖了一个已有的方法,那么极容易导致其它位置的方法调用出现问题。

1.2. method_missing

Ruby对象在调用方法时,如果不能找到目标方法,则会尝试执行method_missing方法,我们可以将method_missing方法看作一层代理:

class Array

  def method_missing(method)
    case method
    when :average
      sum / size
    when :to_binary
      map{ |num| num.to_s(2) }
    end
  end

end

arr = (1..10).to_a

# 如下两个方法都没有直接在 Array 类中定义,而是在查询方法失败以后通过 method_missing 方法进行了处理
puts arr.average # 返回 5
puts arr.to_binary # 返回数组元素转为二进制之后组成的数组

2. 基于prototype和proxy尝试JavaScript元编程

我们知道JavaScript的类实际上是借由prototype实现的语法糖,利用prototype一样可以实现类似于上述的Open Class

const indexAlias = {
    first: 0,
    second: 1,
    third: 2,
    fourth: 3,
    fifth: 4,
    sixth: 5,
    seventh: 6,
    eighth: 7,
    ninth: 8,
    tenth: 9,
    twentieth: 19,
    thirtieth: 29,
    last: -1,
}

Object.keys(indexAlias).map(alias => {
    Array.prototype[alias] = function () {
        const index = indexAlias[alias] === -1 ? this.length - 1 : indexAlias[alias];
        return this[index];
    }
});

const testArr = Object.keys(Array.from(new Array(100)));

console.log(testArr.first()); // 等同于 testArr[0]
console.log(testArr.second()); // 等同于 testArr[1]
console.log(testArr.third()); // 等同于 testArr[2]
console.log(testArr.last()); // 等同于 testArr[3]

上述例子动态的为数组类扩展了多个类似的方法。

不过这里有一个小细节,其实我并不一定需要所有的方法,有时候可能到头来只调用了firstlast方法,但这些方法却实实在在的都挂到了prototype上。

基于代理来实现的方法动态定义其实可以解决这个问题。

const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const customArr = new Proxy(arr, {
    get: function (target, prop, receiver) {
        if (Reflect.has(target, prop)) return Reflect.get(...arguments);
        switch (prop) {
            case 'average':
                return function () {
                    return this.reduce((sum, item) => sum + item, 0) / this.length;
                }
        }
    }
});

console.log(customArr.average());

需要注意几点细节:

  1. 此处通过代理扩展的是实例方法而非类方法
  2. 考虑到数组和对象都可以用字面量的方式完成初始化,打开Array/Object类的时候,或许prototype会更管用一些,因为prototype修改的是原有的类而代理是创建新的类

当然,完全可以将average方法直接放到prototype上,但如果我们要定义的是多个存在联系的方法,使用这种代理会灵活的多,关于这一点,接下来要尝试实现的active_record动态查找可能是一个不错的案例。

3. 基于Proxy实现active_record动态查找

active_recordRuby On Rails中的ORM库,它有一个非常有用的魔法:假设存在一张数据表users,它有三个字段:

  • username
  • nickname
  • email

根据以往我们对ORM的理解,此时需要创建一个实体类,且这个实体类一眼两个需要声明上述的三个属性。不过,在ActiveRecord里,创建实体类你只需要继承ActiveRecord即可,它会自动的添加类属性,同时还有包括如下三个方法在内的大量数据读写方法:

  • find_by_username
  • find_by_nickname
  • find_by_email

其原理是根据数据表的字段名列表动态定义了各字段的查询方法。

JavaScript基于代理也可以实现类似的效果,下面的示例代码没有真正的链接数据库,而是使用了一个对象结构来进行模拟,同时为了让示例看起来像那么回事儿,还实现了active_record持久化数据的两个方法save/create

先来看看最终的效果:

const ActiveRecord = require('./ActiveRecord');

// 1. 初始化一个数据源(模拟数据库)
const DB = {};

ActiveRecord.init({
    db: DB,
});

class User extends ActiveRecord { // 2. 定义一个实体类
}

// 3.1 创建一条数据的方式1: 实例化一个对象然后调用 save 方法
const yuchi = new User({
    userName: 'yuchi',
    password: '123456',
    nickName: '鱼翅'
});

yuchi.save();

// 3.2 创建一条数据的方式2: 直接使用 create 类方法
User.create({
    userName: 'xiaoming',
    password: '11111',
    nickName: '小明'
});

// 4. 查看虚拟的数据库数据 
console.log(DB);

// 5. 通过属性生成的动态查询方法进行查询
console.log(User.findByUserName('yuchi'));
console.log(User.findByNickName('小明'));
console.log(User.findByPassword('11111'));

// 再创建一个
class Book extends ActiveRecord { }

Book.create({ name: '《我们的土地》', author: '[墨西哥] 卡洛斯·富恩特斯', pageTotal: '1036', price: '168', ISBN: '9787521211542' });
Book.create({ name: '《戛纳往事》', author: '[法]吉尔·雅各布', pageTotal: '712', price: '148', ISBN: '9787308211208' });


console.log('查询结果:', Book.findByName('《戛纳往事》'));
console.log('查询结果:', Book.findByAuthor('[法]吉尔·雅各布'));
console.log('查询结果:', Book.findByPageTotal('712'));
console.log('查询结果:', Book.findByPrice('168'));
console.log('查询结果:', Book.findByISBN('9787308211208'));

以下是ActiveRecord类的实现,它有如下细节:

  1. ActiveRecord是一个经过代理的类。
  2. 创建一个ActiveRecord类的子类,然后初始化,实际调用的是父类的构造函数,同时也会触发代理(注意Proxy里的代码,为了保证返回的对象依然是子类对象,手动修改了构造函数指向)。
  3. ActiveRecord类经过代理后,增加了动态查询类方法。
  4. ActiveRecord类的子类实例化后得到的也是一个经过代理的对象,代理中实现了一些实例方法。
// 定义基础的 ActiveRecord 抽象类,并支持动态的初始化实例属性
class BaseActiveRecord {
    constructor(record) {
        Object.keys(record).map(item => this[item] = record[item])
    }

    // 一个用于验证代理后的类依然可以被继承的基础方法,也顺便用于数据序列化以便于存到 DB 中
    toJSON() {
        const res = {};
        Object.keys(this).map(item => res[item] = this[item]);
        return res;
    }
}

// 代理基础 ActiveRecord 类
const ActiveRecord = new Proxy(BaseActiveRecord, {
    // 代理构造方法,主要意图在希望实例化以后返回的 AR 对象一样是被代理过的
    construct: function (target, args, newTarget) {
        const nativeObj = new target(args[0]);
        nativeObj.__proto__ = newTarget.prototype;

        return new Proxy(nativeObj, {
            get: function (obj, prop) {
                if (Reflect.has(obj, prop)) return Reflect.get(...arguments);
                if (prop !== 'save') throw new Error(`${prop} is not a function!`)
                
                // 定义了一个 save 方法,自动根据实体类的名字将数据存到对应的表里
                return function () {
                    const tableName = obj.__proto__.constructor.name.toLowerCase() + 's';
                    ActiveRecord.db[tableName] = (ActiveRecord.db[tableName] || []);
                    ActiveRecord.db[tableName].push(this.toJSON());
                }
            },
        });
    },
    // 代理类属性和方法
    get: function (obj, prop, receiver) {
        if (Reflect.has(obj, prop)) return Reflect.get(...arguments);

        const tableName = receiver.prototype.constructor.name.toLowerCase() + 's';
        switch (prop) {
            // 定义一个 create 方法,基本与 save 方法相同
            case 'create':
                return function () {
                    ActiveRecord.db[tableName] = (ActiveRecord.db[tableName] || []);
                    ActiveRecord.db[tableName].push(arguments[0]);
                }
            default:
                // 根据属性动态定义 findByAttr 方法
                if (prop.startsWith('findBy')) {
                    const attr = prop.slice(prop.indexOf('findBy') + 6, prop.length).toLowerCase();

                    return function () {
                        return ActiveRecord.db[tableName].filter(item => item[Object.keys(item).filter(item => item.toLowerCase() === attr)[0]] === arguments[0]);
                    }
                }
        }
    }
});

// 用来初始化 DB 数据源的配置
ActiveRecord.init = function (option) {
    ActiveRecord.db = option.db;
}

module.exports = ActiveRecord;

代码:
yuchiXiong/activeRecordByProxy

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,277评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,689评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,624评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,356评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,402评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,292评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,135评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,992评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,429评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,636评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,785评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,492评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,092评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,723评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,858评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,891评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,713评论 2 354

推荐阅读更多精彩内容