Type Declaration node-redis-pubsub

@ZungTa · 2022-06-02 목요일 · 8 min read

서론

TypeScript에서 node-redis-pubsub(https://www.npmjs.com/package/node-redis-pubsub)을 사용하던 중 타입 지원이 안 되어서 답답함을 느꼈다.

그래서 @types/node-redis-pubsub을 설치해 봤는데 타입이 부정확했다.

class 형식으로 사용할 수 없었고, message가 실제로는 object인데 타입이 string으로 되어 있어서 사용하기가 불편했다.

그래서 내가 직접 d.ts 파일을 만들면서 겪은 경험을 기록한다.

이 글은 모든 원인과 해결책에 대해서 내가 깊은 이해를 하진 못 한 상태에서 작성했기 때문에 내용이 난해할 수 있다.

결론

tsconfig.json의 compilerOptions.paths에 node-redis-pubsub에 대한 경로를 추가한다.

이 때 d.ts의 파일 이름이 module 이름과 일치하지 않아도 된다.

{
  "compilerOptions": {
    ...
    "paths": {
      ...
      "node-redis-pubsub": ["types/node-redis-pubsub.d.ts"]
    }
  }
}

node-redis-pubsub.d.ts를 작성한다.

같은 이름으로 function과 class, namespace를 만들어주고 export = 로 내보내기를 하면 된다.

interface나 type도 같이 내보내고 싶다면 namespace 안에 만들어 주면 된다.

import * as redis from "redis"

declare function NodeRedisPubSub(
  options: NodeRedisPubSub.NodeRedisPubSubOptions
): NodeRedisPubSub

declare class NodeRedisPubSub {
  constructor(options: NodeRedisPubSub.NodeRedisPubSubOptions)
  getRedisClient(): redis.RedisClientType
  on<MessageType = any>(
    channel: string,
    handler: (data: MessageType, channel: string) => void,
    callback?: () => void
  ): () => void
  subscribe<MessageType = any>(
    channel: string,
    handler: (message: MessageType, channel: string) => void,
    callback?: () => void
  ): () => void
  emit<MessageType = any>(channel: string, message: MessageType): void
  publish<MessageType = any>(channel: string, message: MessageType): void
  quit(): void
  end(): void
}

declare namespace NodeRedisPubSub {
  interface NodeRedisPubSubOptions {
    port?: number
    host?: string
    auth?: string
    socket?: string
    client?: redis.RedisClientType
    redis?: redis.RedisClientType
  }
}

export = NodeRedisPubSub

사용할 때는 import * as NRP from ‘node-redis-pubsub’ 방식으로 사용해주면 된다.

interface나 type도 가져와서 사용할 수 있다.

import * as NRP from "node-redis-pubsub"

const redisOptions: NRP.NodeRedisPubSubOptions = {
  port: 6379,
  host: "localhost",
}

const nrp = new NRP(redisOptions)

type MessageType = {
  id: string
  name: string
  description: string
}

nrp.on<MessageType>("channel", function (message, channel) {
  console.log(
    "message: " + message.id + " " + message.name + " " + message.description
  )
  console.log("channel: " + channel)
})

시행 착오 과정

기존 코드

node-redis-pubsub의 코드는 이렇게 되어 있다.

https://github.com/louischatriot/node-redis-pubsub/blob/master/lib/node-redis-pubsub.js

"use strict"
var redis = require("redis")

function initClient(options) {
  var auth = options.auth
  var redisUrl = options.url
  var client

  options.port = options.port || 6379 // 6379 is Redis' default
  options.host = options.host || "127.0.0.1"

  if (!redisUrl) {
    client = redis.createClient(options)
  } else {
    client = redis.createClient(redisUrl, options)
  }

  if (auth) {
    client.auth(auth)
  }

  return client
}

/**
 * Create a new NodeRedisPubsub instance that can subscribe to channels and publish messages
 * @param {Object} options Options for the client creations:
 *                 port - Optional, the port on which the Redis server is launched.
 *                 scope - Optional, two NodeRedisPubsubs with different scopes will not share messages
 *                 emitter - Optional, a redis or reds_io client
 *                 receiver - Optionla, a redis or reds_io client
 *                 url - Optional, a correctly formed redis connection url
 */
function NodeRedisPubsub(options) {
  if (!(this instanceof NodeRedisPubsub)) {
    return new NodeRedisPubsub(options)
  }

  options || (options = {})

  // accept connections / clients having the same interface as node_redis clients
  // Need to create two Redis clients as one cannot be both in receiver and emitter mode
  // I wonder why that is, by the way ...
  if (options.emitter) {
    this.emitter = options.emitter
  } else {
    this.emitter = initClient(options)
  }

  if (options.receiver) {
    this.receiver = options.receiver
  } else {
    this.receiver = initClient(options)
    this.receiver.setMaxListeners(0)
  }

  delete options.url

  this.prefix = options.scope ? options.scope + ":" : ""
}

/**
 * Return the emitter object to be used as a regular redis client to save resources.
 */
NodeRedisPubsub.prototype.getRedisClient = function () {
  return this.emitter
}

/**
 * Subscribe to a channel
 * @param {String} channel The channel to subscribe to, can be a pattern e.g. 'user.*'
 * @param {Function} handler Function to call with the received message.
 * @param {Function} cb Optional callback to call once the handler is registered.
 *
 */
NodeRedisPubsub.prototype.on = NodeRedisPubsub.prototype.subscribe = function (
  channel,
  handler,
  callback
) {
  if (!callback) callback = function () {}
  var self = this

  if (channel === "error") {
    self.errorHandler = handler
    this.emitter.on("error", handler)
    this.receiver.on("error", handler)
    callback()
    return
  }

  var pmessageHandler = function (pattern, _channel, message) {
    if (self.prefix + channel === pattern) {
      var jsonmsg = message
      try {
        jsonmsg = JSON.parse(message)
      } catch (ex) {
        if (typeof self.errorHandler === "function") {
          return self.errorHandler(
            "Invalid JSON received! Channel: " +
              self.prefix +
              channel +
              " Message: " +
              message
          )
        }
      }
      return handler(jsonmsg, _channel)
    }
  }

  this.receiver.on("pmessage", pmessageHandler)

  this.receiver.psubscribe(this.prefix + channel, callback)

  var removeListener = function (callback) {
    self.receiver.removeListener("pmessage", pmessageHandler)
    return self.receiver.punsubscribe(self.prefix + channel, callback)
  }

  return removeListener
}

/**
 * Emit an event
 * @param {String} channel Channel on which to emit the message
 * @param {Object} message
 */
NodeRedisPubsub.prototype.emit = NodeRedisPubsub.prototype.publish = function (
  channel,
  message
) {
  return this.emitter.publish(this.prefix + channel, JSON.stringify(message))
}

/**
 * Safely close the redis connections 'soon'
 */
NodeRedisPubsub.prototype.quit = function () {
  this.emitter.quit()
  this.receiver.quit()
}

/**
 * Dangerously close the redis connections immediately
 */
NodeRedisPubsub.prototype.end = function () {
  this.emitter.end(true)
  this.receiver.end(true)
}

module.exports = NodeRedisPubsub

@types/node-redis-pubsub의 코드는 이렇게 되어 있다.

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node-redis-pubsub/index.d.ts

import * as redis from "redis"

declare function NRP(options: object): NRP.NodeRedisPubSub
declare namespace NRP {
  function initClient(options: object): NodeRedisPubSub
  class NodeRedisPubSub {
    constructor(options: object)
    getRedisClient(): redis.RedisClient
    on(
      channel: string,
      handler: (data: string, channel: string) => void,
      callback?: () => void
    ): () => void
    subscribe(
      channel: string,
      handler: (data: string, channel: string) => void,
      callback?: () => void
    ): () => void
    emit(channel: string, message: string): void
    publish(channel: string, message: string): void
    quit(): void
    end(): void
  }
}

export = NRP

방법 1

내가 작성한 Declaration 코드 - node-redis-pubsub.d.ts

declare module "node-redis-pubsub" {
  import { RedisClient } from "redis"
  interface NodeRedisPubsubOptions {
    emitter?: RedisClient
    receiver?: RedisClient
    scope?: string
    url?: string
    port?: number
    host?: string
    auth?: string
  }
  class NodeRedisPubsub {
    constructor(options?: NodeRedisPubsubOptions)
    getRedisClient(): RedisClient
    on(
      channel: string,
      handler: (message: any, channel: string) => void,
      callback?: () => void
    ): () => void
    emit(channel: string, message: any): boolean
    quit(): void
    end(): void
  }

  // export 방식과 import하는 방식 중요
  export = NodeRedisPubsub
}

대신 import 하여 사용할 때 주의할 점이 있다.

tsconfig.json의 esModuleInterop 속성을 false로 설정한다.

import할 때는 import NRP = require("node-redis-pubsub") 같은 방식으로 import 한다.

사용 예시

import NRP = require("node-redis-pubsub")

const redisOptions = {
  port: 6379,
  host: "localhost",
}
const nrp = new NRP(redisOptions)

nrp.on("channel", function (message, channel) {
  console.log("message: " + message)
  console.log("channel: " + channel)
})

Declaration File을 수정해서 타입을 정교하게 지정할 수도 있다.

...
on<MessageType = any>(
  channel: string,
  handler: (message: MessageType, channel: string) => void,
  callback?: () => void
): () => void;
...
...
type MessageType = {
  id: string;
  name: string;
  description: string;
};

nrp.on<MessageType>("channel", function (message, channel) {
  console.log(
    "message: " + message.id + " " + message.name + " " + message.description
  );
  console.log("channel: " + channel);
});

과정

Declaration File을 작성할 때 내가 원한 두 가지 조건이 있다.

  1. 기존 JavaScript 버전에서 모듈을 사용하던 것과 같이 class 형식(new NRP)으로 사용할 수 있어야 한다.
  2. tsconfig의 esModuleInterop 속성을 false로 두고 사용할 수 있어야 한다.

처음에는 ESM 방식으로 불러오는 것을 생각하며 Declaration File에서 export를 export default NodeRedisPubsub로 작성하고 import from으로 사용해봤다. TypeScript Compile에서는 오류가 표시되지 않았다.

하지만 실행해보니까 TypeError: node_redis_pubsub_1.default is not a constructor 오류가 발생했다.

이런 방식으로 import해서 사용하려면 esModuleInterop을 true로 설정해야한다

하지만 false로 하기로 했기 때문에 이 방법은 사용하지 않고 다른 방법을 찾아보았다.

import * as NRP from "node-redis-pubsub" 이런 방식으로 import를 해보면 TypeScript Compile 오류가 발생했다.

This expression is not constructable. Type 'typeof import("node-redis-pubsub")' has no construct signatures.

그래서 결국 export = NodeRedisPubsub 처럼 내보내기를 변경하고

import NRP = require("node-redis-pubsub") 방식으로 import 하였다.

방법 2를 완성하고 나니까 방법 1에 적용할 아이디어가 생겼다.

원래는 export default로 내보내기를 하면 import할 때 * as NRP로 해야하기 때문에 사용할 때 NRP.default로 사용을 하도록 오류메시지가 출력되어서 export = 방식으로 export를 했던 것이고 그렇게 export를 하다 보니까 import할 때도 import와 require를 섞어서 쓰는 방식을 사용해야 했다.

namespace를 넣어주니까 export = 로 export를 해도 * as NRP 방식으로 import를 할 수 있게 되었다.

declare module "node-redis-pubsub" {
	...
	namespace NodeRedisPubsub {}
	export = NodeRedisPubsub;
}

방법 2

결론에 있는 방법이다.

tsconfig.json에 compilerOptions.paths 추가하여 해당 모듈에 대하여 Type Declaration을 덮어 씌운다. 이 방법은 declare module 같은 것을 안 해줘도 되고 @types에 들어가는 타입 정의처럼 작성해줘도 되어서 타입을 PR 할 것까지 고려하면 편리한 방식이다.

이 방법의 장점은 declare module을 안 써도 된다는 것이다. @types에 들어가는 타입 정의처럼 작성하면 된다.

이 방법으로 타입 정의를 해서 사용하다가 해당 type 소스에 PR하고 merge되면 이 부분만 제거하고 타입 가져와서 사용하면 된다.

이 방법을 할 때 export와 import 하는 방식 때문에 시행착오를 많이 했는데 답은 namespace에 있었다.

향후 학습하면 좋을 것

  • 왜 namespace를 사용하면 export, import 문제가 해결이 되는지 이론적으로 좀 더 알아보면 좋을 것 같다.

References

@ZungTa
I'm a backend developer
© ZungTa Devlog, Built with Gatsby