Composition in Action: Finishing the Swing
In software engineering, what look to be the easy parts often turn out to be the hard parts. If you’re lucky, the reverse is also true, but don’t count on it. ??
This has certainly been the case with the Typescript refactor of entity-manager, which I proclaimed largely code complete, oh, about a month ago. The job since then was supposed to be largely about documentation, testing, and encapsulating key cross-cutting concerns like injectable logging.
Well, if you’ve been following along, you know that plan blew up in my face.
First I thought I’d encapsulate logging, batching, and related services in some reusable base classes.
This led me head-first into Typescript’s single-inheritance constraint, whereupon I decided to implement these services as mixins.
That produced the loggable library, and seemed to work nicely… until I looked at my generated documentation and realized that TypeDoc doesn’t know what to to with a mixin. Plus the patterns I’d need to consume those mixins were exceedingly ugly. Not a good sign.
But if you click that last link, you’ll also see that I read this result as a strong argument favoring Composition over Inheritance, which is one of those core engineering principles I so often wish I’d been paying attention to all along.
Epiphany #1: We thought we were solving an inheritance problem, but we’re really solving a composition problem!
Now I’m an old guy, which means that pattern-recognition is more of a thing for me than for a younger engineer. It ain’t magic: I’ve just probably seen a lot more patterns than you have.
So here’s a rule of thumb that works pretty well for me: Things get easier and more generic when I’m on the right path!
Case in point: once I decided that composition was the only way I was going to approach the loggable problem, the solution fell right into my lap.
And it’s awesome.
Logging Requirements
Let’s forget about the batchable stuff and focus strictly on loggable. As a recap from previous articles, here’s what we are trying to accomplish.
Say we want to write a widget that does some internal logging. Then…
On the face of it, that seems like a tall order. But there’s something interesting about that list of requirements, and I wonder if you picked up on it.
Give up? Except for the examples I gave, none of those requirements are intrinsically specific to logging! They could just as usefully apply to any injected dependency.
Epiphany #2: We’re not solving a logging problem. We’re solving a dependency injection problem!
Groovy.
And once you start digging around for approaches to dependency injection via composition in Javascript, it won’t be too long until you run into the Proxy object.
The Proxy Object
A Javascript Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.
To illustrate, say you tried to run this code:
console.foo('bar); <== oops!
You’d get a TypeError because the console object doesn’t have a foo method. But what if you could intercept that call and do something else with it?
const consoleProxy = new Proxy(console, {
get: function (target, prop, receiver) {
if (prop in target) {
return target[prop];
} else {
return function () {
console.log(
`You tried to call ${prop} on console, but it doesn't exist!`
);
};
}
},
});
Now if you run the same code:
consoleProxy.foo('bar);
// You tried to call foo on console, but it doesn't exist!
How cool is that??
You can think of a Proxy object as a kind of middleware that sits between your code and the object you’re trying to interact with. It can intercept and modify the behavior of that object in all sorts of ways.
And here’s an important point: the Proxy object does not care what kind of object it proxies! It will expose whatever features the underlying object has, with whatever modifications and extensions you care to make.
So how does this help us with our dependency injection problem?
Epiphany #3: A Proxy object is a universal dependency injector!
领英推荐
Solving The Problem From Both Ends
We have two key requirements that are kind of opposed to one another:
So here’s a strategy:
Still groovy! And, although the application I had in mind was logging, this thing would work for any injectable dependency!
All I had to do was build it.
Introducing controlledProxy
controlledProxy allows the behavior of any object to be modified & controlled non-destructively at runtime. It’s a universal dependency injector that can be used to solve a wide variety of problems.
Installation
npm install @karmaniverous/controlled-proxy
Basic Usage
The controlledProxy function creates a type-safe proxy of any object.
The options parameter is an object with the following properties:
import { controlledProxy } from '@karmaniverous/controlled-proxy';
// Create a controlled console logger. Info messages are disabled by default.
const controlledConsoleLogger = controlledProxy({
defaultControls: { debug: true, info: false },
target: console,
});
// Log messages.
controlledConsoleLogger.debug('debug log');
controlledConsoleLogger.info('info log');
// > debug log
Runtime Control
The proxy object has two special properties, keyed with symbols that can be imported from the package:
import {
controlledProxy,
controlProp,
disabledMemberHandlerProp,
} from '@karmaniverous/controlled-proxy';
// Create a controlled console logger. Info messages are disabled by default.
const controlledConsoleLogger = controlledProxy({
defaultControls: { debug: true, info: false },
target: console,
});
// Disable debug messages & enable info messages at runtime.
controlledConsoleLogger[controlProp].debug = false;
controlledConsoleLogger[controlProp].info = true;
// Log messages.
controlledConsoleLogger.debug('debug log');
controlledConsoleLogger.info('info log');
// > info log
// Change the disabled member handler.
controlledConsoleLogger[disabledMemberHandlerProp] = (
target: Console,
prop: PropertyKey
) => target.log(`Accessed disabled member: ${prop.toString()}`);
// Log messages again.
controlledConsoleLogger.debug('debug log');
controlledConsoleLogger.info('info log');
// > Accessed disabled member: debug
// > info log
Proxy Injection
Here’s an example of the real power of the library: let’s inject a controlled proxy into a class!
import { controlledProxy, controlProp } from '@karmaniverous/controlled-proxy';
// Create a class that accepts a proxied logger as a constructor argument.
class MyClass {
// Proxied logger must be compatible with console.debug & console.info.
constructor(private logger: Pick<Console, 'debug' | 'info'>) {}
// Exercise the proxied logger.
myMethod() {
this.logger.debug('debug log');
this.logger.info('info log');
}
}
// Create a controlled console logger, with all messages enabled by default
// and a custom disabled member handler.
const controlledConsoleLogger = controlledProxy({
defaultControls: { debug: false, info: true },
defaultDisabledMemberHandler: (target: Console, prop: PropertyKey) =>
target.log(`Accessed disabled member: ${prop.toString()}`),
target: console,
});
// Instantiate the class with the controlled console logger.
const myConsoleInstance = new MyClass(controlledConsoleLogger);
// Disable console debug messages at runtime.
controlledConsoleLogger[controlProp].debug = false;
// Exercise the proxied console logger from within the class.
myConsoleInstance.myMethod();
// > Accessed disabled member: debug
// > info log
// Create an equivalent controlled winston logger, with all messages enabled by
// default and a custom disabled member handler.
import { createLogger, type Logger } from 'winston';
const controlledWinstonLogger = controlledProxy({
defaultControls: { debug: true, info: true },
defaultDisabledMemberHandler: (target: Logger, prop: PropertyKey) =>
target.log('warn', `Accessed disabled member: ${prop.toString()}`),
target: createLogger(),
});
// Instantiate the class again with the controlled winston logger.
const myWinstonInstance = new MyClass(controlledWinstonLogger);
// Disable winston debug messages at runtime.
controlledWinstonLogger[controlProp].debug = false;
// Exercise the proxied winston logger from within the class.
myWinstonInstance.myMethod();
// > [winston] { "level":"warn", "message":"Accessed disabled member: debug" }
// > [winston] { "level":"info", "message":"info log" }
Conclusion
It’s fun to show off work you’re proud of, and controlled-proxy is no exception. It’s a neat solution to a deceptively hard problem, and I hope it gets some traction out there.
It also solves my own hard problem, which is cool… all the other dependency injectors I found out there carried far more dependency baggage than I cared to inherit, and this widget feels like it strikes a nice balance.
But that’s not why we’re here, is it?
I hope the three articles in this series have served as a useful illustration of how real software gets done:
Do good work! ??
Visit my website for more great content & tools, all built for you with ?? on Bali!