A cache based on Proxy

Time:2021-5-8

Two years ago, I wrote a blog about business cachingFront end API request caching scheme, this blog has a good response, including how to cache data, promise and how to delete timeout (including how to build decorator). If you don’t know enough about this, you can read the blog to learn.

But the previous code and scheme are simple after all, and they are very invasive to the business. This is not good, so I began to relearn and think about proxy.

Proxy can be understood as setting up a layer of “interception” in front of the target object, through which external access to the object must be intercepted first. Therefore, it provides a mechanism to filter and rewrite external access. The original meaning of the word “proxy” is proxy. It is used here to “proxy” some operations. About the introduction and use of proxy, I suggest you still look at Ruan YifengIntroduction to ECMAScript 6

Project evolution

Any project is not a touch, the following is about the proxy cache library writing ideas. I hope it can help you.

Proxy handler add cache

Of course, the handler parameter in the proxy is also an object. Since it is an object, data items can be added. In this way, we can write the memoize function based on the map cache to improve the recursive performance of the algorithm.

type TargetFun<V> = (...args: any[]) => V

function memoize<V>(fn: TargetFun<V>) {
  return new Proxy(fn, {
    //At present, only one middle layer integration proxy and object can be omitted or added here.
    //Add cache to object
    // @ts-ignore
    cache: new Map<string, V>(),
    apply(target, thisArg, argsList) {
      //Get the current cache
      const currentCache = (this as any).cache
      
      //Generate map key directly according to data parameters
      let cacheKey = argsList.toString();
      
      //Currently not cached, execute call, add cache
      if (!currentCache.has(cacheKey)) {
        currentCache.set(cacheKey, target.apply(thisArg, argsList));
      }
      
      //Returns the cached data
      return currentCache.get(cacheKey);
    }
  });
}

We can try the memoize Fibonacci function. After passing through the proxy function, the performance has been greatly improved (visible to the naked eye)

const fibonacci = (n: number): number => (n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2));
const memoizedFibonacci = memoize<number>(fibonacci);

for (let i = 0; i < 100; i++) fibonacci(30); // ~5000ms
for (let i = 0; i < 100; i++) memoizedFibonacci(30); // ~50ms

User defined function parameters

We can still use the functions introduced in the previous blog to generate unique values, but we no longer need the function name:

const generateKeyError = new Error("Can't generate key from function argument")

//Generating unique values based on function parameters
export default function generateKey(argument: any[]): string {
  try{
    return `${Array.from(argument).join(',')}`
  }catch(_) {
    throw generateKeyError
  }
}

Although the library itself can provide unique values based on function parameters, it is certainly not enough for different businesses. It needs to provide user-defined parameter serialization.

//If there is a normalizer function in the configuration, use it directly; otherwise, use the default function
const normalizer = options?.normalizer ?? generateKey

return new Proxy<any>(fn, {
  // @ts-ignore
  cache,
  apply(target, thisArg, argsList: any[]) {
    const cache: Map<string, any> = (this as any).cache
    
    //Generating unique values based on formatting functions
    const cacheKey: string = normalizer(argsList);
    
    if (!cache.has(cacheKey))
      cache.set(cacheKey, target.apply(thisArg, argsList));
    return cache.get(cacheKey);
  }
});

Add promise cache

In previous blogs, I mentioned the disadvantages of caching data. If it is called many times at the same time, it will make multiple requests because the request does not return. So we also need to add a cache about promise.

if (!currentCache.has(cacheKey)){
  let result = target.apply(thisArg, argsList)
  
  //If it is promise, cache promise, simple judgment! 
  //Promise if the current function has then
  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      //If an error occurs, delete the current promise, otherwise it will cause a second error
      //Because of asynchrony, the current delete call must be after set,
      currentCache.delete(cacheKey)
    
      //Deriving mistakes
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);
}
return currentCache.get(cacheKey);

At this time, we can not only cache the data, but also cache the promise data request.

Add expired delete function

We can add the timestamp of the current cache to the data and add it when the data is generated.

//Cache item
export default class ExpiredCacheItem<V> {
  data: V;
  cacheTime: number;

  constructor(data: V) {
    this.data = data
    //Add system timestamp
    this.cacheTime = (new Date()).getTime()
  }
}

//Edit the middle layer of map cache to determine whether it is expired
isOverTime(name: string) {
  const data = this.cacheMap.get(name)

  //There is no data (because the current saved data is expiredcacheitem), so let's look at the success timeout
  if (!data) return true

  //Get the current time stamp of the system
  const currentTime = (new Date()).getTime()

  //Gets the number of seconds in the past between the current time and the storage time
  const overTime = currentTime - data.cacheTime

  //If the number of seconds in the past is greater than the current timeout, null is also returned to the server to fetch data
  if (Math.abs(overTime) > this.timeout) {
    //This code may not exist and there will be no problem, but if there is this code, you can reduce the judgment by entering the method again.
    this.cacheMap.delete(name)
    return true
  }

  //No timeout
  return false
}

//The cache function has data
has(name: string) {
  //Directly determine whether to timeout in the cache
  return !this.isOverTime(name)
}

At this point, we can do all the functions described in the previous blog. However, if it ends here, it’s too boring. We continue to learn from other libraries to optimize my library.

Add manual management

Generally speaking, these caches have the function of manual management, so here I also provide manual management cache for business management. Here we use the proxy get method to intercept property reading.

return new Proxy(fn, {
  // @ts-ignore
  cache,
  get: (target: TargetFun<V>, property: string) => {
    
    //If manual management is configured
    if (options?.manual) {
      const manualTarget = getManualActionObjFormCache<V>(cache)
      
      // if the function currently called is directly invoked in the current object, no access to the original object.
      //Even if the current function has this property or method, who let you configure manual management.
      if (property in manualTarget) {
        return manualTarget[property]
      }
    }
   
    //Currently, there is no manual management configuration, so you can directly access the original object
    return target[property]
  },
}


export default function getManualActionObjFormCache<V>(
  cache: MemoizeCache<V>
): CacheMap<string | object, V> {
  const manualTarget = Object.create(null)
  
  //Add cache operations such as set get delete clear through closures
  manualTarget.set = (key: string | object, val: V) => cache.set(key, val)
  manualTarget.get = (key: string | object) => cache.get(key)
  manualTarget.delete = (key: string | object) => cache.delete(key)
  manualTarget.clear = () => cache.clear!()
  
  return manualTarget
}

The current situation is not complex. We can call it directly. It is recommended to use it in complex situationsReflect

Add weakmap

When we use cache, we can also provide weakmap (weakmap has no clear and size methods). Here I extract the basecache base class.

export default class BaseCache<V> {
  readonly weak: boolean;
  cacheMap: MemoizeCache<V>

  constructor(weak: boolean = false) {
    //Do you want to use weakmap
    this.weak = weak
    this.cacheMap = this.getMapOrWeakMapByOption()
  }

  //Get map or weakmap according to the configuration
  getMapOrWeakMapByOption<T>(): Map<string, T> | WeakMap<object, T>  {
    return this.weak ? new WeakMap<object, T>() : new Map<string, T>()
  }
}

After that, I add various types of cache classes, all based on this.

Add cleanup function

When the cache is deleted, the value needs to be cleaned up, and the user needs to provide the dispose function. This class inherits basecache and provides a dispose call.

export const defaultDispose: DisposeFun<any> = () => void 0

export default class BaseCacheWithDispose<V, WrapperV> extends BaseCache<WrapperV> {
  readonly weak: boolean
  readonly dispose: DisposeFun<V>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = defaultDispose) {
    super(weak)
    this.weak = weak
    this.dispose = dispose
  }

  // clean up single values (before calling delete)
  disposeValue(value: V | undefined): void {
    if (value) {
      this.dispose(value)
    }
  }

  // clean up all values (called before calling clear method, if current Map has iterator)
  disposeAllValue<V>(cacheMap: MemoizeCache<V>): void {
    for (let mapValue of (cacheMap as any)) {
      this.disposeValue(mapValue?.[1])
    }
  }
}

If the current cache is weakmap, there is no clear method and iterator. I want to add a middle layer to complete all this (still under consideration, not at present). If weakmap calls the clear method, I will directly provide a new weakmap.

clear() {
  if (this.weak) {
    this.cacheMap = this.getMapOrWeakMapByOption()
  } else {
    this.disposeAllValue(this.cacheMap)
    this.cacheMap.clear!()
  }
}

Add count reference

Learning from other librariesmemoizeeI see the following usage:

memoized = memoize(fn, { refCounter: true });

memoized("foo", 3); // refs: 1
memoized("foo", 3); // Cache hit, refs: 2
memoized("foo", 3); // Cache hit, refs: 3
memoized.deleteRef("foo", 3); // refs: 2
memoized.deleteRef("foo", 3); // refs: 1
memoized.deleteRef("foo", 3); //  Refs: 0, clear foo cache
memoized("foo", 3); // Re-executed, refs: 1

So I learned something and added refcache.

export default class RefCache<V> extends BaseCacheWithDispose<V, V> implements CacheMap<string | object, V> {
    //Add ref count
  cacheRef: MemoizeCache<number>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = () => void 0) {
    super(weak, dispose)
    //Generate weakmap or map according to configuration
    this.cacheRef = this.getMapOrWeakMapByOption<number>()
  }
  

  //Get has clear, etc. Not listed
  
  delete(key: string | object): boolean {
    this.disposeValue(this.get(key))
    this.cacheRef.delete(key)
    this.cacheMap.delete(key)
    return true;
  }


  set(key: string | object, value: V): this {
    this.cacheMap.set(key, value)
    //Set and add ref at the same time
    this.addRef(key)
    return this
  }

  //You can also add the count manually
  addRef(key: string | object) {
    if (!this.cacheMap.has(key)) {
      return
    }
    const refCount: number | undefined = this.cacheRef.get(key)
    this.cacheRef.set(key, (refCount ?? 0) + 1)
  }

  getRefCount(key: string | object) {
    return this.cacheRef.get(key) ?? 0
  }

  deleteRef(key: string | object): boolean {
    if (!this.cacheMap.has(key)) {
      return false
    }

    const refCount: number = this.getRefCount(key)

    if (refCount <= 0) {
      return false
    }

    const currentRefCount = refCount - 1
    
    //Set if the current refcount is greater than 0, otherwise clear
    if (currentRefCount > 0) {
      this.cacheRef.set(key, currentRefCount)
    } else {
      this.cacheRef.delete(key)
      this.cacheMap.delete(key)
    }
    return true
  }
}

At the same time, modify the proxy main function:

if (!currentCache.has(cacheKey)) {
  let result = target.apply(thisArg, argsList)

  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      currentCache.delete(cacheKey)
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);

  //Refcounter is currently configured
} else if (options?.refCounter) {
  //If it is called again and has been cached, it will be added directly       
  currentCache.addRef?.(cacheKey)
}

Add LRU

The English full name of LRU is least recently used, which is the least frequently used. Compared with other data structures for caching, LRU is undoubtedly more effective.

Here, consider adding max value as well as maxage value (here I use two maps to make LRU, although it will increase memory consumption, but the performance is better).

If the current data item saved at this time is equal to max, we will directly set the current cachemap to oldcachemap and re create a new cachemap.

set(key: string | object, value: V) {
  const itemCache = new ExpiredCacheItem<V>(value)
  //If there is a value before, modify it directly
  this.cacheMap.has(key) ? this.cacheMap.set(key, itemCache) : this._set(key, itemCache);
  return this
}

private _set(key: string | object, value: ExpiredCacheItem<V>) {
  this.cacheMap.set(key, value);
  this.size++;

  if (this.size >= this.max) {
    this.size = 0;
    this.oldCacheMap = this.cacheMap;
    this.cacheMap = this.getMapOrWeakMapByOption()
  }
}

The key is to get the data. If there is a value in the current cachemap and it has not expired, return it directly. If there is no value, go to oldcachemap to find it. If there is, delete the old data and put in the new data (use_ Set method), if none, returns undefined

get(key: string | object): V | undefined {
  //If cachemap has, it returns value
  if (this.cacheMap.has(key)) {
    const item = this.cacheMap.get(key);
    return this.getItemValue(key, item!);
  }

  //If the oldcachemap contains
  if (this.oldCacheMap.has(key)) {
    const item = this.oldCacheMap.get(key);
    //There is no expiration date
    if (!this.deleteIfExpired(key, item!)) {
      //Move to new data and delete old data
      this.moveToRecent(key, item!);
      return item!.data as V;
    }
  }
  return undefined
}


private moveToRecent(key: string | object, item: ExpiredCacheItem<V>) {
  //Old data deletion
  this.oldCacheMap.delete(key);
  
  //New data setting, key point!!!! If the current set data is equal to max, clear oldcachemap, so that the data will not exceed max
  this._set(key, item);
}

private getItemValue(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  //If maxage is currently set, query, otherwise return directly
  return this.maxAge ? this.getOrDeleteIfExpired(key, item) : item?.data;
}
  
  
private getOrDeleteIfExpired(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  const deleted = this.deleteIfExpired(key, item);
  return !deleted ? item.data : undefined;
}
  
private deleteIfExpired(key: string | object, item: ExpiredCacheItem<V>) {
  if (this.isOverTime(item)) {
    return this.delete(key);
  }
  return false;
}

Organize the memoize function

At this point, we can free ourselves from the previous code details. Let’s take a look at the interfaces and main functions based on these functions.

//Interface oriented, whether or not other types of cache classes will be added later
export interface BaseCacheMap<K, V> {
  delete(key: K): boolean;

  get(key: K): V | undefined;

  has(key: K): boolean;

  set(key: K, value: V): this;

  clear?(): void;

  addRef?(key: K): void;

  deleteRef?(key: K): boolean;
}

//Cache configuration
export interface MemoizeOptions<V> {
  /**Serialization parameters*/
  normalizer?: (args: any[]) => string;
  /**Do you want to use weakmap*/
  weak?: boolean;
  /**Maximum milliseconds, obsolete delete*/
  maxAge?: number;
  /**Maximum number of items, more than deleted*/
  max?: number;
  /**Manage memory manually*/
  manual?: boolean;
  /**Use reference count*/
  refCounter?: boolean;
  /**Callback during cache delete data period*/
  dispose?: DisposeFun<V>;
}

//Function returned (carrying a series of methods)
export interface ResultFun<V> extends Function {
  delete?(key: string | object): boolean;

  get?(key: string | object): V | undefined;

  has?(key: string | object): boolean;

  set?(key: string | object, value: V): this;

  clear?(): void;

  deleteRef?(): void
}

In fact, the final memoize function is similar to the original function, only doing three things

  • Check the parameters and throw an error
  • Get the appropriate cache according to the parameters
  • Return agent
export default function memoize<V>(fn: TargetFun<V>, options?: MemoizeOptions<V>): ResultFun<V> {
  //Check the parameters and throw an error
  checkOptionsThenThrowError<V>(options)

  //Fixed serialization function
  const normalizer = options?.normalizer ?? generateKey

  let cache: MemoizeCache<V> = getCacheByOptions<V>(options)

  //Return agent
  return new Proxy(fn, {
    // @ts-ignore
    cache,
    get: (target: TargetFun<V>, property: string) => {
      //Add manual management
      if (options?.manual) {
        const manualTarget = getManualActionObjFormCache<V>(cache)
        if (property in manualTarget) {
          return manualTarget[property]
        }
      }
      return target[property]
    },
    apply(target, thisArg, argsList: any[]): V {

      const currentCache: MemoizeCache<V> = (this as any).cache

      const cacheKey: string | object = getKeyFromArguments(argsList, normalizer, options?.weak)

      if (!currentCache.has(cacheKey)) {
        let result = target.apply(thisArg, argsList)

      
        if (result?.then) {
          result = Promise.resolve(result).catch(error => {
            currentCache.delete(cacheKey)
            return Promise.reject(error)
          })
        }
        currentCache.set(cacheKey, result);
      } else if (options?.refCounter) {
        currentCache.addRef?.(cacheKey)
      }
      return currentCache.get(cacheKey) as V;
    }
  }) as any
}

Complete code inmemoizee-proxyIn the middle. We operate and play by ourselves.

next step

test

Test coverage is not everything, but in the process of implementing the library,JESTThe test library provides me with a lot of help, which helps me rethink the function and parameter verification that every class and function should have. Before the code I always check in the main entry of the project, for each class or function parameters did not think deeply. In fact, this robustness is not enough. Because you can’t decide how users use your library.

Proxy in depth

In fact, the application scenarios of agent are limitless. This point has been verified by ruby (you can learn “Ruby metaprogramming”).

Developers can use it to create a variety of coding patterns, such as (but far from limited to) tracking attribute access, hiding attributes, preventing modification or deletion of attributes, function parameter verification, constructor parameter verification, data binding, and observable objects.

Of course, although proxy comes from ES6, the API still needs a higher browser versionproxy-pollfillBut after all, it has limited functions. But it’s 2021. I believe it’s time to learn more about proxy.

Deep cache

Caching is harmful! There is no doubt about that. But it’s so fast! So we need to better understand the business, which data needs to be cached, and which data can be cached.

The current writing cache is only for one method. Can the later written items return data in a more fine-grained way? Or do you think more about it and write a cache layer?

Small step development

In the process of developing the project, I used the way of small step and fast running, and constantly reworked. At the beginning of the code, only to add expired delete function that step.

But every time I complete a new function, I start to reorganize the logic and process of the library, and strive for the code to be elegant enough. At the same time, because I don’t have the ability to write for the first time. However, we hope to make continuous progress in our future work. This also reduces the rework of the code.

other

Function creation

In fact, when I added manual management to the current library, I considered copying functions directly, because the function itself is an object. At the same time, add set and other methods for the current function. But there is no way to copy the scope chain.

Although not successful, but also learned some knowledge, here are two functions to create the code.

When we create a function, we basically use new function to create a function, but the browser does not provide a constructor that can directly create an asynchronous function. We need to get it manually.

AsyncFunction = (async x => x).constructor

foo = new AsyncFunction('x, y, p', 'return x + y + await p')

foo(1,2, Promise.resolve(3)).then(console.log) // 6

For global functions, we can also create functions directly by FN. Tostring(). In this case, asynchronous functions can also be constructed directly.

function cloneFunction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
  return new Function('return '+ fn.toString())();
}

Give me some encouragement

If you think this article is good, I hope you can give me some encouragement and help star under my GitHub blog.

Blog address

reference material

Front end API request caching scheme

Introduction to ECMAScript 6

memoizee

memoizee-proxy

Recommended Today

Looking for frustration 1.0

I believe you have a basic understanding of trust in yesterday’s article. Today we will give a complete introduction to trust. Why choose rust It’s a language that gives everyone the ability to build reliable and efficient software. You can’t write unsafe code here (unsafe block is not in the scope of discussion). Most of […]