LoginSignup
0
0

Typescriptでrailsのような(型付き)delegateが実現できるライブラリを作りました

Last updated at Posted at 2024-02-25

モチベーション

Nest.jsではマイクロサービス機能を使って、共通のNest.jsモジュールをCLIとバックエンドのコードを利用できます。その際、サードパーティのライブラリが用意しているメソッドをServiceクラスで再実装せずに使いまわしたい時があります。例えばthis.stiriveService.stripeのような内部インスタンスが露出してカプセル化を破壊するコードは避けたくなりますが、単なるextendなどでクラスを継承してしまうと、DI時に初期化の遅延を考慮する必要があり複雑になります。proxyで遅延する場合はconstructorに遅延処理をラップできるので記述が単純になります。
また、nest-commanderなどで利用しないサービスのクライアントSDK等がロードされて、無駄に起動が遅くなったりしないようにすることができ、リファクタリング時にも、とりあえず古いクラスを消さずにインスタンスの利用したいメソッドだけを移行先のクラスに紐づけておけるので便利です。

成果物

npm i @tkow/ts-delegate等でインストール出来ます。

使い方

インスタンスの内部でデリゲートしたいオブジェクトをProxyでラップするAPIと、インスタンス自体をProxyでラップするAPIを用意しています。前者はインターフェースを定義せずに静的にメソッドのマッピングが出来ます。継承やメソッドのマッピングを行う必要がある反面インスタンス内部のオブジェクトの初期化を遅延することでクラス内部だけでなく、インスタンス初期化後にdelegateを設定することも可能です。

delegateProxy and delegate

内部インスタンスのメソッドをProxyを通じてcallし、内部のインスタンスの型情報を利用して、親クラスのメソッドにマッピングする方法です。

class Parent {
  static DELEGATOR_ID = {
    child1: symbol(),
    child2: symbol(),
  };
  constructor() {
    delegateProxy(this, new Child1(), {
      delegatorId: Parent.DELEGATOR_ID.child1,
    });
    delegateProxy(this, new Child2(), {
      delegatorId: Parent.DELEGATOR_ID.child2,
    });
  }
  hello = delegate<Child1, "hello">("hello", {
    delegatorId: Parent.DELEGATOR_ID.child1,
  });
  goodbye = delegate<Child2, "goodbye">("goodbye", {
    delegatorId: Parent.DELEGATOR_ID.child2,
  });
}

このParentから生成されるインススタンスのhelloとgoodbyeメソッドはそれぞれChild1,Child2のインスタンスメソッドを呼び出します。また、インスタンスメソッドの呼び出しを記述した際に静的型チェックが入ります。

Delegable

Delegableクラスは、引数に渡されたクラスのインターフェースを読み取り、継承されたdelegateAllメソッドを呼び出すことで、指定されたオブジェクトのメソッドを親クラスにマッピングします。親クラスは型情報を継承しそれらの呼び出し記述時に静的型検証をパスしますが、継承されたdelegateAllメソッドを呼び出すまで親クラスはインターフェースのみを継承しメソッドはマッピングされず、インターフェースに定義された親メソッドを呼び出すと実行時エラーになります。これらのメソッドdelegateAllを呼び出した後に実行すると設定したインスタンスにdelegateされます。

class X {
  constructor() {}

  hello = () => {
    return "hello";
  };
}

class Y {
  constructor() {}

  hey = () => {
    return "hey";
  };

  hi = () => {
    return "hi";
  };
}

class Example extends Delegable([X, Y]) {
  constructor() {
    super();
    this.delegateAll(new X());
    this.delegateAll(new Y());
  }
}

const a = new Example();
a.hello();
a.hey();
a.hi();

以上の例ではconstructor内でdelegateAllを呼び出しているため、Exampleインスタンスの初期化後すぐにdelegate先のインスタンスメソッドを呼び出すことが出来ます。delegateAllをconstructor内やクラスのスコープ内で呼び出す必要はなく、初期化後に呼び出すことや一度delegate先をマッピングした後にオーバーライドすることも可能です。

class X {
  constructor() {}

  hello = () => {
    return "hello";
  };
}

class Y {
  constructor() {}

  hello = () => {
    return "hey";
  };
}

class Example extends Delegable([X, Y]) {
  constructor() {
    super();
  }
}

const a = new Example();
this.delegateAll(new X());
a.hello(); // -> hello
this.delegateAll(new Y());
a.hello(); // -> hey

また、delegateAllの引数を関数にすることで、初回の親に定義されていないメソッドのdelegateが呼ばれるまでインスタンスの初期化を遅延することが出来ます。

class X {
  constructor() {}

  hello = () => {
    return "hello";
  };
}

class Example extends Delegable([X]) {
  constructor() {
    super();
    this.delegateAll(() => new X());    
  }
}

const a = new Example(); // この時点では初期化されない
a.hello(); // -> hello  // この時点で初期化される

遅延初期化を含め一度初期化されたインスタンスはクラスの内部でキャッシュされるため、明示的に複数回delegateAllを呼び出さない限り、初期化が複数回実行されることはありません。Delegableの引数や、delegateAllの引数にはoptionとして、内部のオブジェクトからマッピングしたいメソッドだけを指定することもできます。このとき、Delegableの引数が正しく型推論されるようにas constを指定してください。

class X {
  constructor() {}

  hello = () => {
    return "hello";
  };

  goodbye = () => {
    return "goodbye";
  };
}

class Y {
  constructor() {}

  hey = () => {
    return "hey";
  };

  hi = () => {
    return "hi";
  };
}

// NOTE: 型推論のためにas constが必要
class Example extends Delegable([
  { class: X, opts: { delegate: ["hello"] } },
  { class: Y, opts: { except: ["hi"] } },
] as const) {
  constructor() {
    super();
    this.delegateAll(new X());
    this.delegateAll(new Y());
  }
}

const a = new Example();
a.hello(); // ok
a.goodbye(); // 型エラー
a.hey(); // ok
a.hi(); // 型エラー

以上のように選出されたメソッドには型情報が引き継がれ、除外されたメソッド呼び出しは型エラーになります。

ダックタイピング

Delgableの継承クラスではduckTypingメソッドを実装しており、引数にインターフェースが同じインスタンスを指定してダックタイピングができます。このメソッドは内部でdelegateAllを実行しており、delegateの呼び出し先のインスタンスを変更してしまうので注意してください。また、この時、optionでmethodsおよび、classを指定しない限り全てのインスタンスメソッド呼び出しをdelegateしまうので注意してください。この挙動が困る場合は、optionでmethodsあるいはclassにDelegableで指定したクラスを指定してください。methodsは文字列で指定したメソッドに該当するもののみをdelegateします。classは Delegableでdelegate, exceptを指定してdelegateするメソッドを指定している場合、同様に指定されたメソッドのみをデリゲートします。

class X {
  constructor() {}

  hello = () => {
    return "hello";
  };
}

class Animal extends Delegable([{class: X, opts: { delegate: ['hello'] }}]) {
  constructor() {
    super();
  }
}

class Dog {
  hello() {
    return "bow";
  }
}

class Cat {
  hello() {
    return "meow";
  }
}
class Invoker {
  constructor(private animal = new Animal()) {}
  invoke(instance: X) {
    return this.animal.duckTyping(instance, {class: X}).hello();
  }
}

const i = new Invoker();
expect(i.invoke(new Dog())).toBe("bow");
expect(i.invoke(new Cat())).toBe("meow");

応用例

例: Nest.jsでDIされたインスタンスにdelegateを設定します。


class SomeService extends Delegable([{class: Repository, opts: { delegate: ['action'] }}])

  constructor(private repository: Repository) {
     this.delegateAll(repository, {methods})
  }

}

// or

class SomeService {
  
  constructor(private repository: Repository) {
    delegateProxy(repository)
  }

  action = delegate<Repository, 'action'>('action')

}

// or lazy load

class SomeService extends Delegable([{ class: SomeSDK, delegate: ['action'] }]) {
  
  constructor(private repository: Repository) {
    this.delegateAll(getExternalApi)
  }

  getExternalApi() {
    return new SomeSDK('id', "secret api key...")
  }

}

//controller

@Controller()
class SomeController {

  constructor(private service: SomeService) {
  }

  @Get('')
  function index() {
    this.servie.action()
  }

}

delegateは便利な機能なのでぜひ使ってみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0