読者です 読者をやめる 読者になる 読者になる

リズムのじかん

javascript、typescriptなど中心に書きます。

クラスをインタフェースとして使う。mongooseのモデルに適用してみる。

この記事はTypeScript Advent Calendar 2014の25日目の記事です。 少し早めの公開です。

最後の残り一枠が最終日でした(;・∀・)最終日がんばります。

クラスをインタフェースとして使う

typescriptの言語仕様書にこんなことが書かれています。

7.3 Interfaces Extending Classes

When an interface type extends a class type it inherits the members of the class but not their implementations. It is as if the interface had declared all of the members of the class without providing an implementation. Interfaces inherit even the private and protected members of a base class. When a class containing private or protected members is the base type of an interface type, that interface type can only be implemented by that class or a descendant class.

なんと、typescriptのインタフェースはクラスを継承できるらしいです!!

このときprivateメンバも継承する?

(自分はまったく知らなかったけど、実は有名な話だったらごめんなさい。。。)

言語仕様書のサンプルにもありますが、こんなことができます。

class Control {
 private state: any;
}
interface SelectableControl extends Control {
 select(): void;
}

なんかすごいけど、これだけじゃ分からない。そこでいろいろ試してみました。

mongooseのモデルに適用してみる

前置き(余談)

MongoDBのODMとしてmongooseを使っています。typescript + mongooseは個人的に少し使いづらいところがあります。まずはその説明をします。

mongooseの公式サイトのトップにあるサンプルを見てください。(下記)

var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

var Cat = mongoose.model('Cat', { name: String });

var kitty = new Cat({ name: 'Zildjian' });
kitty.save(function (err) {
  if (err) // ...
  console.log('meow');
});

DBに接続して、スキーマ定義して、新しいドキュメント生成してるだけです。シンプルでいいですね。

ただこれをtypescriptでコーディングしていくと、いろいろと躓くのです。 下記はModelのコンストラクタ(上記のサンプルで言うと6行目)の型定義ですが、

export interface Model<T extends Document> {
  new(doc: Object): T;
}

ModelをnewするとDocument(を継承したインスタンス)が返ってきます。

(;・∀・)あれれ、おかしいぞ〜

って感じですね。JavaとかC#とかやってる人から見たらあり得ないですね。 ModelをnewしたらModelのインスタンスが返ってきて欲しい。。

javascriptだからこんなことができるのか。javascriptはやっぱり面白い。

で、mongooseは基本的に、

  1. コレクションに対する操作はModelクラスのメソッドとして実装されています。

  2. ドキュメントに対する操作はDocumentクラスのメソッドとして実装されています。

個人的には、1はDocumentクラスのstaticメソッドとして実装して欲しいです。そうするとRailsActiveRecordや.netのEntityFrameworkに近い形になりますね。

mongooseとの格闘

1. サンプル通り

まず、mongooseの公式サイトにあるサンプルを参考にして、そのままtypescriptに移植すると、インタフェースが複数できたり、クラスが複数できたりと、なぜかどんどん複雑になり、何度も心が折れます。(ちょっと違うけど過去の記事を参照。頑張った割にはあまりきれいにできない。。)

2. typescriptっぽく

サンプルはtypescriptっぽくないんだとやっと気付いて、typescriptっぽく書こうと試みます。

mongooseはDAOっぽい位置づけにして、自分でモデルのクラスを作って、モデルのstaticメソッドにDBのCRUD系ロジックを集約して(mongooseのラッパーみたいなイメージ)、モデルのinstanceメソッドに業務ロジックを集約しよう、と構想します(もちろん一部の業務ロジックはstaticメソッド側にも実装しますが)。

もうインタフェースとかクラスが複数になるのは勘弁、、シンプルにすっきりしたい。

でこれなんですが、もう細かい話は省きますが、できそうでなかなかできないんです。やっとそれっぽい形になったのがこれです。ただまだ冗長な感じがあります。

この時期になると「というか、javascriptでやったらこんなに悩まなくて良かったんじゃない?」(悪魔のささやき)という気分になります。

3. クラスをインタフェースとして使ってみた結果

タイトルに戻って、クラスをインタフェースとして使うっていうのを見つけて、これを適用してみたところ、今までで一番シンプルに書けました。以下がそのコードです。

/// <reference path="../tsd/tsd.d.ts" />

import mongoose = require('mongoose');
import passport = require('passport');
import util = require('../util/Util');

/**
 * MongooseSchema
 * @type {"mongoose".Schema}
 * @private
 */
var _schema: mongoose.Schema = new mongoose.Schema({
    provider: {
      type: String,
      require: true
    },
    id: {
      type: String,
      require: true
    },
    authorId: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'Author'
    },
    displayName: {
      type: String
    },
    emails: {
      type: mongoose.Schema.Types.Mixed
    },
    photos: {
      type: mongoose.Schema.Types.Mixed
    },
    show: Boolean,
    created: {
      type: Date,
      default: Date.now
    },
    updated: {
      type: Date,
      default: Date.now
    }
  })
  .pre('save', function(next) {
    this.updated = new Date();
    next();
  });

interface IUser extends mongoose.Document, User {}

var _model = mongoose.model < IUser > ('User', _schema);

class User {
  provider: string;
  id: string;
  authorId: string;
  displayName: string;
  emails: any;
  photos: any;
  show: boolean;
  private created: Date;
  private updated: Date;

  /**
   * static ユーザが存在しなければ作成して返す。
   * @param passport.Profile
   * @returns {Promise<User>}
   */
  static findOrCreate(profile: passport.Profile): Promise < User > {
    return new Promise < User > ((resolve, reject) => {
      _model.findOne({
          provider: profile.provider,
          id: profile.id
        })
        .exec()
        .then(user => {
          if (user) {
            return resolve(new User(user));
          }
          _model.create({
              provider: profile.provider,
              id: profile.id,
              displayName: profile.displayName,
              emails: profile.emails,
              photos: profile.photos
            })
            .onResolve((err, user) => {
              err ? reject(err) : resolve(new User(user));
            });
        });
    });
  }

  /**
   * static idからUserオブジェクトを取得
   * @param id
   * @returns {Promise<User>}
   */
  static findById(id: string): Promise < User > {
    return new Promise < User > ((resolve, reject) => {
      _model.findById(id)
        .exec()
        .onResolve((err, user) => {
          // debug
          console.log(user.created); // コンパイルOK
          // console.log(user.findOrCreate);  // コンパイルNG
          err ? reject(err) : resolve(new User(user));
        });
    })
  }

  /**
   * コンストラクタ
   * @param mongoose.Document<User>
   */
  constructor(user: IUser) {
    util.extend(this, user.toObject());
  }

  get image(): string {
    if (Array.isArray(this.photos)) {
      return this.photos.length > 0 ? this.photos[0] : null;
    }
    return this.photos;
  }
}

export = User;

ポイントは以下です。

  • 49行目でインタフェースがクラスを継承しています。このインタフェースは、mongoose.DocumentとUserクラスのプロパティ・メソッドを使えます。

  • 104〜106行目は検証用のデバッグログを出力しています。105行目で、privateなプロパティもインタフェースに継承されました。これはこういう仕様ということでしょう。

  • 106行目で、Userクラスのstaticメソッドはインタフェースに継承されませんでした。これは賢い!!

本題とは関係ないですが、その他のポイントとして、

最後に上記のコードで満足していないところは以下です。

  • Userクラスのプロパティがpublicになっている。privateにしてgetterを用意しても良いが、プロパティと同じ名前で定義できない。 →個人的には値を再代入しないというルール決めで対応。

  • ここまで触れませんでしたが、「クラスをインタフェースとして使う」ということに若干の違和感を感じる。←今更(笑)

今回はインタフェースがクラスを継承しましたが、クラスがクラスをimplementsすることとかができるのかな?。scalaのtraitっぽいことができたら夢が広がります。これはまた今度。

今年一年お疲れさまでしたノシ