Metaprogramming with Proxy

In an earlier example, in Injecting Multiple Properties, we introduced a few missing properties like first and last into arrays. That’s member injection—we knew what to introduce or inject at code writing time. While that’s fun and useful, member synthesis ups the challenge and the resulting benefits by a few notches. With synthesis we can bring on board new members into a class based on runtime context. We can also alter the behavior of existing methods or properties dynamically based on the current state at runtime. To achieve this, we need the help of the Proxy class. We’ll first explore this newly added class in JavaScript and then employ it for method synthesis.

The Proxy Class

An instance of the Proxy class stands in for another object or a function—also known as target—and can intercept or trap calls to fields, methods, and properties on its target.

To create a proxy, provide two things:

  • a target—the proxy stands in for this
  • a handler—this traps and intercepts calls on the target

Use the handler to trap any operation that may be performed on an object or a function. By default, if you don’t trap a call it defaults to a method on Reflect, as illustrated in the figure, so that the calls are forwarded to the target.

images/proxy.png

We can use Proxy to synthesize behavior on a class, but before we get to that, let’s see how to use a Proxy.

Creating a Dummy Proxy

A proxy with no handler acts like a dummy, merely forwarding all calls, through Reflect, to its target. Creating a dummy is a good starting point before breathing life into a proxy, so let’s start with that. But first, we need a class to play with. Here’s an Employee class with some fields and properties:

 class​ Employee {
 constructor​(firstName, lastName, yearOfBirth) {
 this​.firstName = firstName;
 this​.lastName = lastName;
 this​.yearOfBirth = yearOfBirth;
  }
 
 get​ fullname() { ​return​ ​`​${​this​.firstName}​ ​${​this​.lastName}​`​; }
 get​ age() { ​return​ ​new​ Date().getFullYear() - ​this​.yearOfBirth; }
 }
 
 const​ printInfo = ​function​(employee) {
  console.log(​`First name: ​${employee.firstName}​`​);
  console.log(​`Fullname: ​${employee.fullname}​`​);
  console.log(​`Age: ​${employee.age}​`​);
 };
 
 const​ john = ​new​ Employee(​'John'​, ​'Doe'​, 2010);

The Employee class has a constructor to initialize a few fields and has two properties. The function printInfo() displays the firstName field, with the fullname and age properties of the object given as parameters. Finally, john is an instance of Employee that we’ll use to create proxies for.

Let’s create a dummy proxy for the john instance.

 const​ handler = {};
 const​ proxyDoe = ​new​ Proxy(john, handler);
 printInfo(proxyDoe);

We created an instance of Proxy using new, and we provided the target john as the first argument to the constructor and an empty handler as the second argument. Since there are no traps in the handler, the Proxy will route all calls on the proxy to the underlying target. We can see this behavior from the output of calling printInfo() with the dummy proxy as the argument:

 First name: John
 Fullname: John Doe
 Age: 8

Let’s continue to keep the proxy as dummy for a little longer but quietly spy on property access next.

Creating a Trap to Spy

The dummy proxy we created has no traps in its handler. For each method that’s available on Reflect, we can optionally create a trap in the handler. To create a trap to be used when reading an object’s property, provide a get() function in the handler.

The trap function for get() takes three arguments:

  • target: This is same as the first argument we passed to the constructor of Proxy.

  • propertyName: This is the property we are trying to get. For example, if we called proxy.foo, then propertyName has the value "foo".

  • receiver: This is the proxy that receives the call.

Let’s spy on the call to read a property and report what we observe. After reporting the details, we’ll forward the call to the target via Reflect. That is, we’ll manually do what the Proxy does by default if we do not provide a trap.

 const​ handler = {
  get: ​function​(target, propertyName, receiver) {
 if​(propertyName === ​'firstName'​) {
  console.log(​`target is john? ​${john === target}​`​);
  console.log(​`propertyName is ​${propertyName}​`​);
  console.log(​`receiver is proxyDoe? ​${proxyDoe === receiver}​`​);
  }
 
 return​ Reflect.​get​(target, propertyName);
  }
 };

We only updated the handler; the rest of the code to create the proxy proxyDoe and to pass it to printInfo() remains unchanged. In the updated handler, we added a trap for the get() function—a handler is an object with trap names as keys and the corresponding intercepting functions as values. Within this trap, we verify that the three parameters passed to the function are the target, the name of the property being requested, and the proxy that receives the request, respectively. Finally, since the trap merely acts as a spy, it passes the property call to the intended target using the get() method of Reflect.

Even though when writing a class we use the get keyword only to define properties, the get() trap of a proxy is called at the time any field, method, or property is accessed. Thus, in the example with the updated handler, the get() trap will intercept calls to the field firstName as well as the properties fullName and age on proxyDoe.

 target is john? true
 propertyName is firstName
 receiver is proxyDoe? true
 First name: John
 Fullname: John Doe
 Age: 8

The proxy with the updated handler spied on the call, but it’s still a dummy; it indiscreetly forwarded each operation to the target. Let’s breathe some life into the proxy next, to truly intercept and alter the behavior during calls.

Altering Behavior Using Traps

Instead of forwarding every call to the target, let’s modify the behavior of the proxy to take some action. printInfo() is asking for firstName and then fullName. But it then goes on to ask for age—how rude. Let’s convey that feeling to the caller by changing the handler again.

 const​ handler = {
  get: ​function​(target, propertyName, receiver) {
 if​(propertyName === ​'age'​) {
 return​ ​`It's not polite to ask that question, dear`​;
  }
 
 return​ Reflect.​get​(target, propertyName);
  }
 };

In this modified version of the trap, we intercept the request for the age property and forward all other read accesses to the target. When age is requested, we return a message of rejection instead of the real value.

 First name: John
 Fullname: John Doe
 Age: It's not polite to ask that question, dear

The trap may instead return an adjusted value of age (whoever tells their real age anyway?) or throw an exception, or perform any action that may be appropriate for the application at hand.

The above use of proxy, to restrict access to some members of a class, is an example of the Control Proxy pattern presented in the popular Design Patterns: Elements of Reusable Object-Oriented Software [GHJV95].

Leasing an Object Using a Revocable Proxy

You’ve learned to create proxies and write traps; let’s move on to applying this knowledge. Our target is method synthesis, but let’s explore one other benefit of proxies: the ability to lease an object.

Suppose we want to limit the lifetime of an object. A function that creates an object with new may use it as long as it wants, as long as the instance reference is in scope. If you want to return an object to a caller but withdraw or revoke access to that object either after some time or when some condition is met, use a revocable proxy.

Here’s a counterFactory() function that creates an instance of a Counter class but returns a revocable proxy to it instead of the original object.

 const​ counterFactory = ​function​() {
 class​ Counter {
 constructor​() { ​this​.value = 0; }
  increment() { ​this​.value += 1; }
 
 get​ count() { ​return​ ​this​.value; }
  }
 
 const​ { proxy: counterProxy, revoke: revokeFunction } =
  Proxy.revocable(​new​ Counter(), {});
 
 const​ leaseTime = 100;
  setTimeout(revokeFunction, leaseTime);
 
 return​ counterProxy;
 };

In the function, instead of using new Proxy() we used Proxy.revocable() to create a proxy. Much like Proxy’s constructor, the revocable() method takes a target and a handler as parameters. However, unlike a call to new on constructor, which returns a new instance of Proxy, here we get an object with two properties: proxy and revoke. The proxy property refers to the new instance of Proxy created, and the revoke property is a reference to a function that, when called, will revoke the privilege to use the proxy and hence the underlying target.

As the next step, in the counterFactory() function we scheduled a call to the revoke function at the end of an arbitrary lease time. You can revoke based on some other event instead of time as well. Finally we return the proxy to the caller of counterFactory().

Let’s take a look at an example of code that calls the counterFactory() method.

 const​ counter = counterFactory();
 
 const​ incrementAndDisplay = ​function​() {
 try​ {
  counter.increment();
  console.log(counter.count);
  setTimeout(incrementAndDisplay, 20);
  } ​catch​(ex) {
  console.log(ex.message);
  }
 };
 
 incrementAndDisplay();

The incrementAndDisplay() function invokes the increment() method on the counter instance that’s in the lexical scope, displays the current value of the counter, and schedules another asynchronous call to itself. In case something were to go wrong with these calls, the exception handler will report the error and not schedule any further calls to the function.

Let’s run the code and see how it fares.

 1
 2
 3
 4
 5
 Cannot perform 'get' on a proxy that has been revoked

The incrementAndDisplay() function happily marched along until counterFactory() revoked access; at that point, our effort to invoke increment() on the counter instance met a terrible fate.

The error message says “Cannot perform ’get’ on a proxy…” instead of saying it can’t invoke ’increment’ or something like that. The reason for this is, as you’ll recall from Creating a Trap to Spy, the get() handler on Proxy is called for any field, property, or method.

Before we move on to the next topic, let’s do a quick refactoring to reduce some noise in the counterFactory() function. In this function, we have the following code:

 const​ { proxy: counterProxy, revoke: revokeFunction } =
  Proxy.revocable(​new​ Counter(), {});

That’s destructuring, but it’s a bit smelly. We can reduce the clutter using the default property mapping capability of destructuring you learned in Chapter 6, Literals and Destructuring. Before you look at the next piece of code, spend a few minutes refactoring the previous clumsy code. When done, compare the code you created with the following:

 const​ { proxy, revoke } = Proxy.revocable(​new​ Counter(), {});
 
 const​ leaseTime = 100;
 setTimeout(revoke, leaseTime);
 
 return​ proxy;

Since the object returned by Proxy.revocable() has properties named proxy and revoke, we can use the same names for our local variables. Thus, instead of revokeFunction now the revoke function is referenced by the variable named revoke. Likewise, instead of the variable counterProxy to hold the proxy, now the proxy resides in the variable named proxy. Less noise leads to better code.

Intercepting Function Calls Using Proxy

Aspect-oriented programming (AOP) is a special case of metaprogramming where function calls may be intercepted with advices. An advice is a piece of code that is exercised in a particular context. In life we often receive three kinds of advices: good, bad, and unsolicited. AOP also has three kinds of advices:

  • Before advice: runs before the intended function call
  • After advice: runs after the intended function call
  • Around advice: runs instead of the intended function

Logging is the most infamous example of AOP advices since it’s overused by authors. We may inject a call to log parameters passed to a function, for informational or debugging purposes. Or we may log the result of a function call before it’s passed back to the caller.

There are many other uses of AOP advices. For instance, we may use them to monitor the context of execution of a function call, to check for authorization or permission to call, to alter arguments passed to a function, or to change a URL from production to test server. There are countless scenarios where we can use AOP. Let’s see how to create advices with an example.

Proxy can be used to implement AOP like advices. Recollect the apply() function of Reflect from Invoking a Function Through Reflect—it’s the alternative for apply() on functions. Proxy’s handler routes any call to apply on a Proxy, by default, to Reflect. We can override that implementation to inject advices.

Let’s start with a function that returns a greeting string given a message and name.

 const​ greet = ​function​(message, name) {
 return​ ​`​${message}​ ​${name}​!`​;
 };
 
 const​ invokeGreet = ​function​(func, name) {
  console.log(func(​'hi'​, name));
 };
 
 invokeGreet(greet, ​'Bob'​);

The invokeGreet() function receives a function reference as the first parameter and a name as the second argument. It then calls the given function and prints the result. With no AOP advices, let’s make a call to the greet function via the invokeGreet() function.

 hi Bob!

Implementing a Before Advice

The message passed as the first argument by invokeGreet() to greet() is a lowercase hi. Let’s use AOP before the advice to capitalize that. In this approach, the caller invokeGreet() isn’t going to change, nor will the target greet() be altered. We’ll intercept and transform the first argument, and then forward the argument to greet().

 const​ beforeAdvice = ​new​ Proxy(greet, {
  apply: ​function​(target, thisArg, args) {
 const​ message = args[0];
 const​ msgInCaps = message[0].toUpperCase() + message.slice(1);
 
 return​ Reflect.apply(target, thisArg, [msgInCaps, ...args.slice(1)]);
  }
 });
 
 invokeGreet(beforeAdvice, ​'Bob'​);

We create a new Proxy with greet as the target. In the handler, we override the apply() function. This function, by default, calls Reflect.apply(). However, in the overridden implementation we intercept and transform the arguments before the call goes to the target method.

 Hi Bob!

The before advice may perform any operations it desires, call services, transform arguments, log details about the call—whatever it wants based on the needs of the application.

Implementing an After Advice

Instead of, or in addition to, the before advice, we can perform an after advice, a piece of code that runs after the call. The after advice can optionally transform the result of the function call. In the case of logging, for example, it’ll merely note the result and return it to the caller. However, if we like we can transform the output or return a different output, depending on the needs of the application.

Let’s write a new before and after advice for the greet() function. In it, we’ll alter the message argument before the call and, after the call, transform to uppercase the result of the call before returning the result to the caller.

 const​ beforeAndAfterAdvice = ​new​ Proxy(greet, {
  apply: ​function​(target, thisArg, args) {
 const​ newArguments = [​'Howdy'​, ...args.slice(1)];
 
 const​ result = Reflect.apply(target, thisArg, newArguments);
 
 return​ result.toUpperCase();
  }
 });
 
 invokeGreet(beforeAndAfterAdvice, ​'Bob'​);

Instead of storing the result in the result variable, we could have called toUpperCase() directly after the closing parenthesis of the Reflect.apply(...) call; introducing that variable makes it clear that we’re performing a post-call operation to return the result:

 HOWDY BOB!

In this example, we took the result returned by Reflect.apply() and transformed it before returning. This example assumes that nothing goes wrong. But in general we have to program defensively. Wrap the call to Reflect.apply() in either a try-finally or try-catch-finally. If you like an after advice to run no matter the success or failure of the function, then put the advice in the finally block. If you want an advice to run only upon successful return from the function, then place it after the function call within the try block. If an advice should run only in the case of failure, then place it in the catch block.

Implementing an Around Advice

“Should I take these pills before food or after food?” asked the patient. “I suggest you take it instead of the meal,” joked the doctor. That’s kind of what an around advice is; it hijacks the call and provides an alternative implementation. The around advice may be selective; it may bypass the call based on some conditions—the values of arguments, some external state or configuration parameter, and so forth.

Let’s write an around advice for the greet() function.

 const​ aroundAdvice = ​new​ Proxy(greet, {
  apply: ​function​(target, thisArg, args) {
 if​(args[1] === ​'Doc'​) {
 return​ ​"What's up, Doc?"​;
  }
 else​ {
 return​ Reflect.apply(target, thisArg, args);
  }
  }
 });
 
 invokeGreet(aroundAdvice, ​'Bob'​);
 invokeGreet(aroundAdvice, ​'Doc'​);

In the advice, we check if the second argument is equal to ’Doc’ and bypass the call to the greet() function and instead return an alternative response. Otherwise, we continue with the call to the original method. Here’s the result of making two calls to the invokeGreet() function.

 hi Bob!
 What's up, Doc?

We saw the different capabilities of Proxy. One of the most charming facilities it offers is method synthesis. Let’s learn how to use that capability next.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset