Mongoose の callback を Promise に移行する手法

Developer Experience & Performance チームでエンジニアをしている大矢です。今回は JavaScript の非同期処理で使われている callback を Promise に移行する手法について、具体的な事例をもとに紹介します。

背景

プレイドでは MongoDB Atlas という MongoDB のマネージドサービスを使っており、クライアントライブラリにはほとんどのシステムで Mongoose を使用しています。

MongoDB Atlas では2025年7月に MongoDB 6.0 が EOL になるため、MongoDB 7.0 にバージョンアップする必要があります。また、そのためには Mongoose のバージョンを 6.x から 7.x に上げる必要があります。

6.x から 7.x にアップデートする際に最も大きく変更が必要だったのは dropped-callback-support です。これは callback と Promise 両方を扱える API から Promise のみを扱う API への破壊的変更という意味になります。callback と Promise の両方を扱えるというのは、具体的には callback 関数を引数として渡さない場合に Promise を返し、callback 関数が渡された場合にはその関数を実行するというものです。

CRUD を含め Mongoose の内部で使われている全ての関数がこの変更の対象になります。例えば複数のデータを作成する insertMany の実装は次のようになっています。

https://github.com/Automattic/mongoose/blob/6.x/lib/model.js#L3385

Model.insertMany = function(arr, options, callback) {
  _checkContext(this, 'insertMany');

  if (typeof options === 'function') {
    callback = options;
    options = null;
  }
  return this.db.base._promiseOrcallback(callback, cb => {
    this.$__insertMany(arr, options, cb);
  }, this.events);
};

引数の arr 以外は省略可能で、第二引数か第三引数に callback 関数が渡されると promiseOrCallback の第一引数に callback 関数が渡されます。options も callback も関数ではない場合は promiseOrCallback の第一引数は undefined になります。

また、promiseOrCallback の実装は次のようになっています。

https://github.com/Automattic/mongoose/blob/6.x/lib/helpers/promiseOrcallback.js#L8

// 説明のために一部省略しています
module.exports = function promiseOrCallback(callback, fn, ee, Promise) {
  if (typeof callback === 'function') {
    try {
      return fn(function(error) {
        if (error != null) {
          callback(error)
        }
        callback.apply(this, arguments);
      });
    } catch (error) {
      return callback(error);
    }
  }

  return new Promise((resolve, reject) => {
    fn(function(error, res) {
      if (error != null) {
        return reject(error);
      }
      if (arguments.length > 2) {
        return resolve(Array.prototype.slice.call(arguments, 1));
      }
      resolve(res);
    });
  });
};

ここで、promiseOrCallback の第一引数が callback 関数の場合には処理の実行後に callback 関数を実行し、そうでない場合には Promise を返すようになっています。

弊社の主要プロダクトである KARTE は 2015 年にローンチされたため、古めのコードでは callback スタイルが多く使われています。色々省略して書きますが、次のようなコードが至る所にあります。

const UserSchema = new Schema({
  email: { type: String, required: true },
  name: { type: String, required: true },
});

// リリース時にコネクションが急増するのを防ぐために、
// ファイルが読み込まれた時点ではコネクションが貼られないように関数の形にしている
const UserModel = () => mongoose.model('User', UserSchema);

export const findUser = (userId, callback) => {
  // ...何らかの処理
  UserModel().findOne({ _id: userId }, (err, user) => {
    if (err) {
      return callback(err)
    }
    // ...何らかの処理
    return callback(null, result)
  })
}

このように callback 関数を渡す API を利用している箇所は非常に数が多く、1つ1つ callback 関数を渡さない方式に書き直していくのは時間がかかりますし、作業ミスも発生します。

そのため、スクリプトを使ってコードを解析して書き換えたいと考えました。

書き換えの方針

書き換え方はざっくり3通りほど考えられます。

  1. async/await で書き換える
  2. util.callbackify でラップする
  3. then, catch で書き換える

それぞれの手法のサンプルコードおよびメリット・デメリットについて書いてきます。

1. async/await で書き換える

async/await で書き換える場合 findUser 関数は次のようになります。

export const findUser = async (userId) => {
  // ...何らかの処理
  const user = await UserModel().findOne({ _id: userId });
  // ...何らかの処理
  return result
}

この書き換え手法では、書き換え後のコードが最も可読性が高く、変更しやすいものになっているというメリットがあります。

実際に Mongoose の migration ガイドには ChatGPT により async/await 方式に書き換える方法が提供されています。 https://github.com/mastering-js/masteringjs-backend/blob/main/awaitify.js

しかし、これはあくまで Mongoose のように callback 関数を省略したときに Promise が返るような実装をしている部分にしか適用できず、それ以外の部分は自分で書き換える必要があります。

この書き換え手法が難しいと感じた理由をまとめると下記の通りです。

  • await を使うため async を宣言する必要があり、findUser 関数自体のインタフェースが変更されます。

  • 「何らかの処理」に callback 関数を受け取るような関数がある場合、それらも全て書き直す必要があります。

    before

    export const findUser = (userId, callback) => {
      hoge(userId, (err) => {
        if (err) {
          return callback(err)
        }
        // ...何らかの処理
        UserModel().findOne({ _id: userId }, (err, user) => {
          if (err) {
            return callback(err)
          }
          // ...何らかの処理
          return callback(null, result)
        })
      });
    }
    

    after

    export const findUser = async (userId) => {
      await hoge(userId)
      // ...何らかの処理
      const user = await UserModel().findOne({ _id: userId });
      // ...何らかの処理
      return result
    }
    

    ただし、hoge 関数は Promise に対応してるかはわからないため、場合によっては util.promisify などでラップする必要があります。

2. util.callbackify でラップする

util.callbackify でラップする場合、JavaScript であれば findUser 関数は次のようになります。

import { callbackify } from 'node:util'
export const findUser = (userId, callback) => {
  // ...何らかの処理
  callbackify(UserModel().findOne)({ _id: userId }, (err, user) => {
    if (err) {
      return callback(err)
    }
    // ...何らかの処理
    return callback(null, result)
  })
}

この書き換え手法では、 UserModel().findOne の部分を callbackify() で囲っただけなので、コードの変更量が少ないというメリットがあります。findOne に渡した callback 関数の中身は一切変える必要がありません。しかし、TypeScript の場合は上記のコードはコンパイルエラーになります。詳しくは次の章で解説します。

3. then, catch で書き換える

then, catch で書き換える場合 findUser 関数は次のようになります。

export const findUser = (userId, callback) => {
  UserModel.findOne({ _id: userId })
    .then(user => {
      // ...何らかの処理
      callback(null, result)
    })
    .catch(err => {
      callback(err)
    })
}

この書き換え手法では、callback 関数の中身を正常系の処理の部分とエラーハンドリングの部分で分割し、それぞれ then, catch の引数に関数として渡します。使い勝手としては callbackify でラップするのと特に変わらず、書き換えの難易度は callbackify でラップする方式よりも若干高そうに見えます。

それぞれの手法を「書き換え容易性」「書き換え後のコードの可読性・保守性」の観点で比較すると次のようになると予想しました。

async/await callbackify then, catch
書き換え容易性 × ◎(?)
書き換え後のコードの可読性・保守性 △(?)

util.callbackify でラップする手法の検証

実際の書き換え容易性は書き換えの手段にもよるので、まずは callbackify でラップする手法から、どのように書き換えられそうか検証していきました。上述した通り、ただ callbackify でラップするだけだと TypeScript ではコンパイルエラーが起きます。

callbackify(UserModel.findOne)({ _id: userId }, (err, user) => { // Expected 1 arguments, but got 2. ts(2554)

可変長引数とオーバーロードに起因する問題

コンパイルエラーを修正するにあたって、可変長引数とオーバーロードを使った findOne の型定義について理解する必要があります。Mongoose 7.x において findOne の型定義は次のようになっています。

https://github.com/Automattic/mongoose/blob/7.x/types/models.d.ts#L326

  export interface Model {
    // ...省略
    findOne<ResultDoc = THydratedDocumentType>(
      filter: FilterQuery<TRawDocType>,
      projection: ProjectionType<TRawDocType> | null | undefined,
      options: QueryOptions<TRawDocType> & { lean: true }
    ): QueryWithHelpers<
      GetLeanResultType<TRawDocType, TRawDocType, 'findOne'> | null,
      ResultDoc,
      TQueryHelpers,
      TRawDocType,
      'findOne'
    >;
    findOne<ResultDoc = THydratedDocumentType>(
      filter?: FilterQuery<TRawDocType>,
      projection?: ProjectionType<TRawDocType> | null,
      options?: QueryOptions<TRawDocType> | null
    ): QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>;
    findOne<ResultDoc = THydratedDocumentType>(
      filter?: FilterQuery<TRawDocType>,
      projection?: ProjectionType<TRawDocType> | null
    ): QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>;
    findOne<ResultDoc = THydratedDocumentType>(
      filter?: FilterQuery<TRawDocType>
    ): QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>;

TypeScript では型のオーバーロードがあり、先に定義されたものから順にマッチするか判定されます。1つ目の定義は options に { lean: true } が含まれる場合にのみ採用される型定義です。この型定義を用意しているのは返り値の型が変わるためです。残りの3つの型定義は callback 関数を受け付けていた時の名残かと思います。オーバーロードを活用せずに callback 関数を受け付けるような型定義を作ろうとすると非常に複雑になります。例えば次のような型だと、filter と projection など複数の引数を callback 関数にすることができますし、callback 関数が常に最後の引数になるとは限りません。

  type callback<T = any> = (error: callbackError, result: T) => void;
  
  export interface Model {
  // ...省略
    findOne<ResultDoc = THydratedDocumentType>(
      filter?: FilterQuery<TRawDocType> | callback<ResultDoc | null>,
      projection?: ProjectionType<TRawDocType> | callback<ResultDoc | null> | null,
      options?: QueryOptions<TRawDocType> | callback<ResultDoc | null> | null,
      callback?: callback<ResultDoc | null>,
    ): QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>;

このように、特定のオプションによって戻り値の型が変わる場合や、引数は省略可能で最後に callback 関数を渡すといった慣習を表現するのにオーバーロードは便利です。

また、callbackify の型定義は次のようになっています。

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/util.d.ts#L1054-L1122

  declare module "util" {
    // ...省略
    export function callbackify(fn: () => Promise<void>): (callback: (err: NodeJS.ErrnoException) => void) => void;
    export function callbackify<TResult>(
        fn: () => Promise<TResult>,
    ): (callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void;
    export function callbackify<T1>(
        fn: (arg1: T1) => Promise<void>,
    ): (arg1: T1, callback: (err: NodeJS.ErrnoException) => void) => void;
    export function callbackify<T1, TResult>(
        fn: (arg1: T1) => Promise<TResult>,
    ): (arg1: T1, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void;
    
    // ... 省略
    
    export function callbackify<T1, T2, T3, T4, T5, T6, TResult>(
        fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Promise<TResult>,
    ): (
        arg1: T1,
        arg2: T2,
        arg3: T3,
        arg4: T4,
        arg5: T5,
        arg6: T6,
        callback: (err: NodeJS.ErrnoException | null, result: TResult) => void,
    ) => void;

Promise<void> を返す場合と void 以外の場合で返り値のパターンが2つあり、あとは引数の数が6個まで対応できるようになっており、引数が少ない順に型定義がされています。

引数が少ない順に型定義がされているため、可変長引数の関数を callbackify の引数として渡した場合には、optional な引数は無いものとして扱われ推論されます。さらに、オーバーロードされた関数型から取り出せるのは最後のシグネチャの内容だけです。

typeof Model.findOne // => (filter?: FilterQuery<TRawDocType>): QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>;

つまり、findOne を callbackify でラップすると下記のように callback 関数のみを引数に取る関数と推論されてしまいます。

callbackify(UserModel().findOne); // => (callback: (err: NodeJS.ErrnoException, result: QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'>) => void) => void

callbackify の型パラメータを明示的に渡すことでこれを回避することができます。

// 実際には UserModel から推論する必要があります
type UserRawDocType = {
  email: string;
  name: string;
}

callbackify<
  FilterQuery<UserRawDocType>, 
  QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, UserRawDocType, 'findOne'>
>(UserModel().findOne)({ _id: userId }, (err, user) => { // ...省略

一気に複雑になってしまいましたが、このやり方だと findOne の第一引数が FilterQuery<RawDocType> であることや、返り値が QueryWithHelpers<...> であることをあらかじめ知っている必要があり、自動化が困難です。そのため ParametersReturnType を使って推論することを考えました。愚直にやろうとすると次のようなコードになるかと思います。次のコードはコンパイルが通ります。

type UserModelType = ReturnType<typeof UserModel> // UserModel は mongoose.Model<UserRawDocType> を返す関数

callbackify<
  Parameters<UserModelType['findOne']>[0], 
  Awaited<ReturnType<UserModelType['findOne']>>,
>(UserModel().findOne)({ _id: userId }, (err, user) => { // ...省略

引数が増えた場合には次のように書けそうです。しかしこれはコンパイルエラーになります。

callbackify<
  Parameters<UserModelType['findOne']>[0], 
  Parameters<UserModelType['findOne']>[1], // Tuple type '[filter?: FilterQuery<UserRawDocType> | undefined]' of length '1' has no element at index '1'
  Awaited<ReturnType<UserModelType['findOne']>>,
>(UserModel().findOne)({ _id: userId }, { _id: 1, email: 1 }, (err, user) => { 

Parameters<UserModelType['findOne']>[filter?: FilterQuery<UserRawDocType> | undefined] と推論されているようです。これは先ほど説明した通りオーバーロードされた関数型から取り出せるのは基本的には最後のシグネチャの内容だけだからです。しかし、オーバーロードされた関数型の任意の位置のシグネチャを取り出せる方法があります(オーバーロードされた関数型から引数の型や返り値の型を取り出す方法)。次のようにすることで、findOne の一番最初ののシグネチャを取り出すことができ、第二引数の型が取れるためコンパイルを通すことができるようになります。

export type FindOne<T extends Model> = T['findOne'] extends {
  (...args: infer Params): infer Ret;
  (...args: any[]): any;
  (...args: any[]): any;
  (...args: any[]): any;
}
  ? { params: Params, ret: Ret }
  : never;
  
callbackify<
  FindOne<UserModelType>['params'][0], 
  FindOne<UserModelType>['params'][1],
  Awaited<FindOne<UserModelType>['ret']>,
>(UserModel().findOne)({ _id: userId }, { _id: 1, email: 1 }, (err, user) => { 

引数の型に関するコンパイルエラーを回避することができましたが、大きな問題があります。

  • findOne などの Mongoose の Model, Query に生えている関数毎に、それぞれの関数がいくつのオーバーロードされた関数型があるかをあらかじめ把握しておく必要がある
    • さらに Mongoose 側のアップデートに追従する必要がある
    • Mongoose 6 と 7 でも型定義に差分があり、上記のコードは Mongoose 7 ではコンパイルが通るが Mongoose 6 では通らないため、Mongoose 6 から 7 へのバージョンアップと Promise への書き換えを同時にリリースする必要があり、リスクが大きい
  • findOne の場合、一番上の型定義は { lean: true } を第3引数に渡した時のみに使われる型なので、戻り値の型が { lean: true } を渡さなかった場合と異なる

メソッドチェーンに起因する問題

findOne の引数に関してはコンパイルエラーは起きなくなりましたが、実は上記のコードでは callback 関数の引数となっている user の型は正しく推論されていません。

callbackify<
  FindOne<UserModelType>['params'][0], 
  FindOne<UserModelType>['params'][1],
  Awaited<FindOne<UserModelType>['ret']>,
>(UserModel().findOne)({ _id: userId }, { _id: 1, email: 1 }, (err, user) => { 
  console.log(user.email) // Property 'email' does not exist on type 'Query<unknown, unknown, {}, UserRawDocType, "findOne", {}>'. ts(2339)
})

user は Query という型のようです。一般的には、 Promise<T> を返す関数は awaitthen などで Promise が解決されると T を返します。下記のコードで result1 result2 はそれぞれ string 型に推論されています。

const asyncFunction: () => Promise<string> = async () => 'hoge';
const result1 = await asyncFundtion();
asyncFunction().then(result2 => result2);

一方で、findOne の返り値は QueryWithHelpers<ResultDoc | null, ResultDoc, TQueryHelpers, TRawDocType, 'findOne'> となっており、Promise でラップされていません。どういうことかというと、Mongoose ではメソッドチェーンのパターンが使われています。例えば次の2つの findOne 呼び出しは同じ処理を実行します。

await UserModel.findOne({ _id: userId }, { email: 1 }, { lean: true });
await UserModel.findOne({ _id: userId }).select({ email: 1 }).lean().exec();

2行目の findOne select lean メソッドはそれぞれ Query オブジェクトを返しています。チェーンされた関数が呼ばれるたびに Query オブジェクトの this が更新されます。ちなみに exec はクエリを実行するための関数で、Promise を返します(Mongoose 6 以前は findOne と同じく callback が省略された場合に Promise を返します)。 1行目では exec を実行していませんが、実際にはクエリが実行されます。これは Query オブジェクトに then, catch が実装されているからです。

https://github.com/Automattic/mongoose/blob/7.x/lib/query.js#L4576

Query.prototype.then = function(resolve, reject) {
  return this.exec().then(resolve, reject);
};

Query.prototype.catch = function(reject) {
  return this.exec().then(null, reject);
};

Query の then, catch の型定義は次のようになっています。

https://github.com/Automattic/mongoose/blob/7.x/types/query.d.ts#L733

  class Query<ResultType, DocType, THelpers = {}, RawDocType = DocType, QueryOp = 'find'> implements SessionOperation {
    // ...省略
    catch: Promise<ResultType>['catch'];
    exec(): Promise<ResultType>;
    then: Promise<ResultType>['then'];

Query オブジェクトは Promise ではないですが、then と catch を実装することで TypeScript 上でも Query オブジェクトを返すメソッドを Promise を返すメソッドのように扱うことができています。次のように、exec が返す型を Awaited したものが user の正しい型になります。

callbackify<
  FindOne<UserModelType>['params'][0], 
  FindOne<UserModelType>['params'][1],
  Awaited<ReturnType<FindOne<UserModelType>['ret']['exec']>>,
>(UserModel().findOne)({ _id: userId }, { _id: 1, email: 1 }, (err, user) => { 
  console.log(user.email)
})

then, catch で書き換える

検証の結果、util.callbackify でラップする手法はかなり問題が多いことがわかりました。改めて「書き換え容易性」「書き換え後のコードの可読性・保守性」の観点を比較します。

async/await callbackify then, catch
書き換え容易性 × ×
書き換え後のコードの可読性・保守性 ×

実質 then, catch での書き換え以外のやり方で自動化することは厳しいかなと感じ、then, catch での書き換えを検討しました。

書き換えパターン

まずは書き換えるべき箇所を特定する必要があります。次のような課題を解く必要があります。

  • Mongoose のメソッドだということをどう特定するか
  • 引数に callback 関数が使われていることをどう特定するか

TypeScript としての型が推論できる場合にはどちらも比較的容易に解けそうです。例えば、あるメソッドのレシーバの型が mongoose.Model を継承していれば Mongoose のメソッドだと言えます。そのメソッドの最後の引数の型が function 型であれば、引数に callback 関数が使われていると言えます。

しかし実際には、callback スタイルのコードが残っているような箇所は元々 JavaScript などで書かれていたため十分に型が効いていませんでした。型に頼って書き換え箇所を特定するのは不安定だと考え、シンプルに Mongoose の Model 等に定義されているメソッド名を全て洗い出してそれらとマッチするかをチェックしました。

Mongoose には find や create など、他のライブラリでもよく使われるような名前のメソッドも定義されています。KARTE では Mongoose の Model は全て下記のコードのように関数呼び出しで取得するため、find や create の呼び出し元が関数呼び出しの形になっているかのチェックを加えることで、関係ない find や create などが誤検知されないようにしました。

const UserModel = () => mongoose.model('User', UserSchema);
UserModel().find({ _id: userId })

次に callback 関数がメソッドの引数に渡されているかについてですが、下記のように匿名関数を渡している場合には簡単に判別できそうです。

UserModel().find({ _id: userId }, (err, user) => {
  // ...なんらかの処理
})

しかし JavaScript は関数を値として扱うため、次のように書くことも可能です。

const callback = (err, user) => {
  // ...なんらかの処理
}
UserModel().find({ _id: userId }, callback)

このように値に代入された関数を find などに渡している場合は正確に callback 関数が渡されているかどうかを判定することは難しいです。上記のようなコードであればまだなんとかなりそうですが、次のように型のついていない引数が callback 関数の可能性もあります。

const findUser = (userId, callback) => {
  UserModel().find({ _id: userId }, callback)
}

KARTE では、callback 関数を値として扱うときに、 cbcallback という名前にする慣習があります。全てをカバーできるわけではありませんが、Mongoose の関数呼び出しの最後の引数が cb もしくは callback という名前のものも、書き換えの対象にするという方針にしました。

書き換えパターンの洗い出し

callback 関数として匿名関数を渡すパターン・関数を値として渡すパターンは下記のように書き換えられます。

// 書き換え前
UserModel().find({ _id: userId }, (err, user) => {
  if (err) {
    // ...エラー処理
    return callback(err)
  }
  // ...正常処理
  return callback(null, result) // result は正常処理の結果
})

// 書き換え後
UserModel().find({ _id: userId })
  .then(user => {
    // ...正常処理
    callback(null, result)
  })
  .catch(err => {
    // ...エラー処理
    return callback(err)
  })
// 書き換え前
UserModel().find({ _id: userId }, callback)

// 書き換え後
UserModel().find({ _id: userId })
  .then(user => {
    callback(null, result)
  })
  .catch(callback)

さらに、メソッドチェーンのスタイルで書かれたコードは次のように書き換えられます。

// 書き換え前
UserModel().find({ _id: userId }).exec((err, user) => {
  if (err) {
    // ...エラー処理
    return callback(err)
  }
  // ...正常処理
  return callback(null, result) // result は正常処理の結果
})

// 書き換え後
UserModel().find({ _id: userId }).exec()
  .then(user => {
    // ...正常処理
    callback(null, result)
  })
  .catch(err => {
    // ...エラー処理
    return callback(err)
  })

書き換え手段の検討

上記のような書き換えのパターンを実現できそうな手段をいくつか検討しました。関数のネスト等の複雑なパターンがあるため AST を扱える必要があります。初めに検討したのは ast-grep という AST を扱える置換ツールです。下記のようなコマンドを実行することで「匿名関数を渡すパターン」の書き換えを行うことができます。

ast-grep --pattern '$MODEL().findOne($$$ARGS1, function ($ERR, $DOC) { if (err) { $$$STATEMENT1 } $$$STATEMENT2 })' --rewrite '$MODEL().findOne($$$ARGS1).then(($DOC) => { $$$STATEMENT2 }).catch((err) => { $$$STATEMENT1 })'

VSCode の拡張機能もありプレビューで確認できるのも便利なポイントです。パターンが少ない場合はこれでも良いのですが、下記のようなパターンも考慮すると結局置換ルールを自動で作成するためのスクリプトを書かないといけなくなりそうです。

  • function 式とアロー関数など匿名関数の syntax パターン
  • find, findOne, findOneAndUpdate などの関数のパターン
  • 括弧の省略などのパターン
  • callback 関数が第二引数をとるか

ある程度最初から自由度のある選択肢をとっておいた方が柔軟に対応できそうということで、recast という AST transformer を使用して書き換えスクリプトを実装しました。

recast による書き換えスクリプト実装の注意点

事前に洗い出した書き換えのパターンを実現できるようなスクリプトを実装していきましたが、いくつかうまくいかない部分がありました。

CallExpression がネストされているパターン

async.map(arr, function () {
  UserModel().find({ _id: userId }, (err, user) => {
    if (err) {
      // ...エラー処理
      return callback(err)
    }
    // ...正常処理
    return callback(null, result) // result は正常処理の結果
  })
})

上記のように対象の CallExpression が CallExpression のサブツリーになっている場合に置換が漏れるケースがありました。これは traverse を呼び出すことで解決できました。

  visit(ast, {
    // 関数呼び出しを検知する
    visitCallExpression(path) {
      // ...なんらかの書き換え処理
      this.traverse(path);
    },
  });

匿名関数のエラーハンドリングが特殊なパターン

UserModel().find({ _id: userId }, (err, user) => {
  // ...なんらかの共通の処理
  if (err) {
    // ...エラー処理
    return callback(err)
  }
  // ...正常処理
  return callback(null, result) // result は正常処理の結果
})
UserModel().find({ _id: userId }, (err, user) => {
  if (err || !user) { // err が truthy である以外の条件が入る
    // ...エラー処理
    return callback(err)
  }
  // ...正常処理
  return callback(null, result) // result は正常処理の結果
})

1つ目は find の処理が成功しても失敗しても何らかの処理が実行されます。このようなパターンでは then, catch それぞれに同じ処理を書くか finally を挟む必要があります。

2つ目はエラー処理が行われる条件に callback 関数の第二引数が関係するパターンです。このような場合には then の中で条件分岐を行い、 return callback(err) を実行する必要があります。

他にもいくつか厄介なパターンがあったのですが、このようなパターンに対応するような処理は書かずに手作業で直すことにしました。理由としては特殊なパターンに分類されるような実装の数が少なかったことと、書き換えスクリプトの実装を必要以上に複雑にしたくなかったからです。

匿名関数の最初の処理が if (err) { 処理 } ではない場合、そのファイル名と行数を出力し、特殊なパターンに対応しやすくしました。

書き換え漏れを洗い出す仕組み

上記のような書き換え手法では、アプリケーション内の全ての Mongoose の関数が callback 関数を受け取らないようになったと保証することはできません。例えば callback 関数を値として渡している箇所が必ず cb もしくは callback という名前になっているとは限りません(実際に end という名前の callback 関数がありました。)し、下記のようにレシーバが関数呼び出しではないが Mongoose のメソッドを使用してるコードがあるかもしれません。

const userModel = UserModel();
userModel.find({ _id: userId }, (err, user) => {

さらに、Mongoose 7 にあげたときに callback 関数を扱っている箇所がコンパイルエラーを出してくれるとも限りません。 UserModel() が any を返す可能性もありますし、 cb という変数が any の可能性もあります。

callback 関数が渡されるような使い方がされたかどうかを検知する仕組みとして、patch-package を使って Mongoose のライブラリ実装にパッチを当てました。 findOne にこのパッチを当てたサンプルコードが下記になります。

Model.findOne = function findOne(conditions, projection, options, callback) {
  _checkContext(this, 'findOne');
  if (typeof options === 'function') {
    callback = options;
    options = null;
  } else if (typeof projection === 'function') {
    callback = projection;
    projection = null;
    options = null;
  } else if (typeof conditions === 'function') {
    callback = conditions;
    conditions = {};
    projection = null;
    options = null;
  }
  
+  if (callback) {
+    const trace = new Error("detect usage of callback ").stack;
+    console.log(trace.replace(/\n/g, '2028'));
+  }

  const mq = new this.Query({}, {}, this, this.$__collection);
  mq.select(projection);
  mq.setOptions(options);

  callback = this.$handlecallbackError(callback);
  return mq.findOne(conditions, callback);
};

ちなみに、 promiseOrcallback 関数の中にこの実装を入れるということも考えましたが、全ての callback 関数を受け取るようなメソッドの内部で promiseOrcallback が呼ばれているわけではなさそうだったので、Model や Query に定義されている callback 関数を受け取るようなメソッド全てに上記のような実装をしました。

さらにこの実装により出力したログは、Datadog で stacktrace ごとにグループ化し、1つ1つ手作業で直していきました。

detectusageofcallback.png

この仕組みにより50箇所程度の書き換え漏れが検知できました。そのうちの30箇所程度は先ほど説明した「callbackExpression がネストされているパターン」だったので、書き換え自動化スクリプトを修正し、実際に手作業で直した箇所は20箇所程度でした。この仕組みを作っておくことにより、「複雑なロジックを書いてまで自動書き換えするべきか」の判断がより精度良くできるようになったと思います。

移行作業の結果

今回の書き換えの自動化によって書き換えた行数はテストコードを含めて 4000 行程度でした。この大量な作業をミスなく人手で行うのは困難だったと思うので、自動化できてよかったと思います。また、書き換え漏れを洗い出す仕組みにより実際の Mongoose 6 から 7 へのバージョンアップも安全に行うことができました。実際に callback 関数を渡すような実装が残っていてエラーが発生するということはありませんでした。

最後に

recast による書き換えスクリプトの実装や patch-package を使った検出の仕組みは全てインターン生の SotaSato-stst くんが実装してくれました。短い期間で TypeScript や AST transformer のキャッチアップをしつつ実装をやりきってくれて、本当に助かりました。

あまりないかもしれませんが、callback を Promise に書き換えるような機会があれば参考にしていただけると幸いです。