Result (Code Simplification)

Preface
From a previous blog post
In this article we have implemented
- an
ApplicationEventPublisher
and addEventHandler
that essentially adds aeventName
to(() => Promise<void>)[]
pair In this article we aim at simplifying it as in the result image above, which looks exactly the same as how spring boot works without any boilerplate code.
compilerOptions
of tsconfig.json
compilerOptions
of tsconfig.json
"compilerOptions": { "target": "ES2015", "experimentalDecorators": true, "emitDecoratorMetadata": true, ... }
Stage-3 or Stage-2 Decorators?
As of now (2024-11-09) the development of decorators in javascript has reached stage3
with breaking changes from stage2
.
The npm package reflect-metadata
that returns the reflection
of the metadata of class
and class-method
relies heavily on stage2
-decorators and therefore we have to set:
--experimentalDecorators
and--emitDecoratorMetadata
to ensure we are usingstage2
-decorators.
decorators.ts with a simple example
The imports and Constants
import "reflect-metadata" export type ListenerMetadata = { methodName: string; handler: Function; eventName: string; order: number; nextEvent: string } export const NEXT_LISTENER_METADATA_KEY = "nextListener"; export const LISTENER_METADATA_KEY = "listeners"; const LISTENER_ORDER_KEY = "order"
-
reflect-metadata
is used to get the metadata of classes and methods inside of adecorator function
. -
LISTENER_METADATA_KEY
andLISTENER_ORDER_KEY
defines thekey
for setting akey-value
pair whose value is to be the metadata of a class, or a method. -
As we shall see later, when we want to store
method
level data, we useReflect.defineMetadata(LISTENER_ORDER_KEY, methodLevelData, target, propertyKey);
where
target
is the class,propertyKey
is the name of a method, the tuple(LISTENER_METADATA_KEY, class, methodname)
defines a key for the
methodLevelMetaData
. -
Similarly, when we want to store
class
level data, we useReflect.defineMetadata(LISTENER_METADATA_KEY, classLevelData, target.constructor);
where
(LISTENER_METADATA_KEY, target.constructor)
defines a key for theclassLevelMetaData
.It should be clear
target
is theclass
-Object in this context.
@order
export function order(order: number) { return function (...args: any[]) { const [target, propertyKey, descriptor] = args; Reflect.defineMetadata(LISTENER_ORDER_KEY, order, target, propertyKey); return descriptor; }; }
@nextEvent
export function nextEvent(nextClass: Function) { return function (...args:any[]) { const [target, propertyKey] = args; Reflect.defineMetadata(NEXT_LISTENER_METADATA_KEY, nextClass, target, propertyKey); }; }
@listener
export function listener(...args: any[]) { const [target, propertyKey, descriptor] = args; const eventName = Reflect.getMetadata("design:paramtypes", target, propertyKey)[0].name; const existingListeners: ListenerMetadata[] = Reflect.getMetadata(LISTENER_METADATA_KEY, target.constructor) || []; const order: number = Reflect.getMetadata(LISTENER_ORDER_KEY, target, propertyKey) || 0; const nextEvent = (Reflect.getMetadata(NEXT_LISTENER_METADATA_KEY, target, propertyKey)?.name || "") as string ; existingListeners.push({ methodName: propertyKey.toString(), handler: descriptor.value!, eventName: eventName, order, nextEvent }); Reflect.defineMetadata(LISTENER_METADATA_KEY, existingListeners, target.constructor); return descriptor; }
Note that we have added order
and nextEvent
here, which means that @order
and @nextEvent
should be executed before @listener
, this is achieved by
@listener @order(1) // or @nextEvent(NextEvent) @nextEvent(NextEvent) // @order(1) async someMethod() { ... }
because decorators are executed in bottom-up manner.
Let's print out the metadata
// Test class InputParam { } class AnotherParam { } class Example { // decorator executes from bottom to top @listener @order(1) method1(param: InputParam) { console.log("Method 1:", param); } @listener @order(2) method2(param: AnotherParam) { console.log("Method 2:", param); } } class BetterExample extends InputParam {} const listeners: ListenerMetadata[] = Reflect.getMetadata(LISTENER_METADATA_KEY, Example); console.log(listeners);
We get the following listeners' metadata:
[ { methodName: 'method1', handler: [Function: method1], eventName: 'InputParam', order: 1 }, { methodName: 'method2', handler: [Function: method2], eventName: 'AnotherParam', order: 2 } ]
This provides all enough information to register listeners to the events.
Simplified applicationEventPublisher and ApplicationEvent
1import { db } from "@src/db/kysely/database"; 2 3export type ContextBaseType = { 4 userEmail: string 5} 6 7export class ApplicationEvent< 8 DataType, 9 ContextType extends ContextBaseType = ContextBaseType, 10> { 11 constructor(public data: DataType, public ctx: ContextType) { } 12}
-
Unlike previous post in
ApplicationEvent
we have replacedResultType
byContextType
. -
This
Context
is supposed to be shared by all events within a cycle (those that would be chained together).Imagine a fallback loop (starts from
SomethingFailedEvent
) would be infinite, we need some value in the context to stop us from retrying indefinitely.
15export type EventHandler<Event = any> = (event: Event) => void | Promise<void>; 16type OrderedEventHandler<Event = any> = { handle: EventHandler<Event>; order: number }; 17 18class ApplicationEventPublisher { 19 private handlers: Map<string, OrderedEventHandler[]> = new Map<string, OrderedEventHandler[]>(); 20 21 constructor() { 22 } 23 24 async publishEvent<D, C extends ContextBaseType>(event: ApplicationEvent<D, C>): Promise<void> { 25 const eventName = Object.getPrototypeOf(event).constructor.name as string; 26 const handlers = this?.handlers?.get(eventName)?.sort((a, b) => a.order - b.order); 27 if (handlers) { 28 for (const handler of handlers) { 29 const handle = handler.handle as EventHandler<typeof event>; 30 await handle(event); 31 await db.insertInto("event_store").values({ 32 event_type: eventName, 33 payload: { data: event.data, ctx: event.ctx } 34 }).execute(); 35 } 36 } 37 } 38 39 addEventListener = <T>(evenType: string, handler: EventHandler<T>, order: number = 1): void => { 40 if (!this.handlers.has(evenType)) { 41 this.handlers.set(evenType, []); 42 } 43 this.handlers.get(evenType)!.push({ handle: handler, order }); 44 }; 45} 46 47export const applicationEventPublisher = new ApplicationEventPublisher(); 48export default ApplicationEventPublisher;
The highlighted block of codes is responsible for storing historical event data. Storing into memory-store such as redis
and flush it back to our PostgreSQL by bulk insert is much more appropriate.
Class DecoratedListenersRegister: Register the decorated listeners
The register
method
register
methodBy the previous concrete example (the console.log(listeners);
in the previous section right above) the register
method is straight forward:
1export default class DecoratedListenersRegister { 2 private listenersRecord: ListenerMetadata[] = []; 3 4 register(listenerClass: Object) { 5 const listeners: ListenerMetadata[] = Reflect.getMetadata( 6 LISTENER_METADATA_KEY, 7 listenerClass 8 ); 9 10 for (const listener of listeners) { 11 // nextEvent is not used here, we add it here to remind 12 // the readers we have this value available! 13 const { nextEvent, methodName, eventName, handler, order } = listener 14 this.listenersRecord.push(listener) 15 applicationEventPublisher.addEventListener( 16 eventName, 17 handler as EventHandler<any>, 18 order 19 ) 20 } 21 } 22
The plotRelation
method
plotRelation
methodImplementation of plotRelation
plotRelation
-
We will make use of the
Node
for doubly linked list defined in this blog post. -
Since our services are decomposed into many small events, some may consider the services as highly-decoupled to the extent that is hard to follow.
-
One solution is to add @nextEvent(NextEvent) attribute to indicate the
NextEvent
class to be the next target, from that we can plot the relation for easy-debugging (and also see all the events available in the system!) -
Let's make use of the
nextEvent
property to chain all the connected events.
Disclaimer 1. I am not proficient in data-structure and algorithm, I am using doubly-linked list just because I feel that
appendLeft
andappendRight
should be appropriate methods for connecting separated "events" ( and into , for example)
Disclaimer 2. The following
for
-loops may produce redundancies, any improvement to reduce unnecessary loops is welcome.
23 plotRelation() { 24 const items = this.listenersRecord.map(item => new Node({ data: item, uuid: uuidv4() })) 25 const results: Node<{ data: ListenerMetadata, uuid: string }>[] = []; 26 const freezedNodes = Object.freeze(cloneDeep(items)); 27 // don't mutate freezedNodes as we need to loop through completely 28 for (const currNode of freezedNodes) { 29 for (let j = 0; j < freezedNodes.length; j++) { 30 for (const connectedItem of currNode.traverse()) { 31 const eventNamesToSkip = currNode.traverse().map(node => node.value.data.eventName); 32 for (const traverseItem of freezedNodes) { 33 if (!eventNamesToSkip.includes(traverseItem.value.data.eventName)) { 34 if (traverseItem.value.data.nextEvent === connectedItem.value.data.eventName) { 35 connectedItem.appendLeft(traverseItem); 36 const removeIndex = items.findIndex(item => item.value.uuid === traverseItem.value.uuid); 37 items.splice(removeIndex, 1); 38 } 39 else if (connectedItem.value.data.nextEvent === traverseItem.value.data.eventName) { 40 connectedItem.appendRight(traverseItem); 41 const removeIndex = items.findIndex(item => item.value.uuid === traverseItem.value.uuid); 42 items.splice(removeIndex, 1); 43 } 44 } 45 } 46 } 47 } 48 const shouldIgnore = results 49 .map(node => node.getHead().value.data.eventName) 50 .includes(currNode.getHead().value.data.eventName) 51 if (!shouldIgnore) { 52 results.push(currNode); 53 } 54 } 55 56 const lifeyCycles: string[] = []; 57 for (const item of results) { 58 let eventLifeCycle = "Start --> "; 59 const events = item.getHead().traverse(); 60 events.forEach((node, i) => { 61 const { eventName, nextEvent, methodName } = node.value.data; 62 const nextEventNode = freezedNodes.find(n => n.value.data.eventName === nextEvent); 63 const nextMethodName = nextEventNode?.value.data.methodName || "" 64 if (i === 0) { 65 eventLifeCycle += `${eventName} (${methodName})`; 66 if (nextEvent) { 67 eventLifeCycle += `\n --> ${nextEvent} (${nextMethodName})` 68 } 69 } else { 70 if (nextEvent) { 71 eventLifeCycle += `\n --> ${nextEvent} (${nextMethodName})` 72 } 73 } 74 }) 75 if (events.length === 1) { 76 eventLifeCycle += " --> End" 77 } else { 78 eventLifeCycle += "\n --> End" 79 } 80 lifeyCycles.push(eventLifeCycle); 81 } 82 83 const eventsRelation = lifeyCycles.join("\n"); 84 return eventsRelation; 85 } 86}
The result of visualized event-cycles
Start --> IssueSummaryUuidRelationInsertedEvent (insertRelIssueToSummaryUuid) --> End Start --> WalkOpenedEvent (openWalkOn) --> End Start --> WalkClosedEvent (closeWalkOn) --> End Start --> IssueUpdatedEvent (updateIssueOn) --> End Start --> LLMSummaryPlaceholderCreatedEvent (createLLMPlaceHolderOn) --> End Start --> LLMSummaryStep1GenerationCommand (generateFreshLLMSummaryOn) --> LLMSummaryStep2TranslateAndSaveCommand (transateAndSaveFreshSummaryOn) --> LLMSummaryStep3GeneateAndSendExcelCommand (generateAndSendExcelOn) --> End Start --> LLMSyncToAzureCommand (syncToAzureOn) --> End Start --> LLMSummaryRegenerationCommand (regenerateSummaryOn) --> End Start --> TranslateAndSaveRegeneratedSummaryCommand (translateAndSaveRegeneratedSummaryOn) --> End