抽象化は「依存注入」と「固有名詞を使わないこと」さえおさえればとりあえずOK!
なぜ抽象化がわからないのか
自分の理解度に従って以下の章からよみすすめてみることをおすすめします。
- 抽象化の目的がわからない -> 「前提 なぜ抽象化が大事なのか」
- 抽象化を実際に使う場面がわからない -> 「プラクティス 抽象化ポイントをみつける」
- 抽象化のパターンがわからない -> 「ユースケース 抽象化パターン」
前提 なぜ抽象化が大事なのか
なぜ抽象化が大事かと言われると、抽象化がシステムのデザインにおけるベストプラクティスだからです(デザインはそのまま訳せば設計ですが、設計というと実装前のプロセスというニュアンスが強くなるので避けました。ここでデザインといっているのはプログラムに限らず、フレームワークやサーバーなどのハードウェア含めてありとあらゆる場面においての設計というニュアンスを込めたかったためです)。プログラムでもハードでも、抽象度が高いものを組み合わせることで、変更に強く、また交換しやすくすることが可能になります。以下の2つの視点からこのメリットを具体的に見ていきます。
- 設計レベルでの組み合わせやすさ
- 実装レベルでの交換可能性
設計レベルでの組み合わせやすさ
思考実験です。PCを分解してみます。PCを構成する最小単位の部品が以下の4種類だったとします(ありえませんね笑)。
最小単位の部品4つ。
()
[]
<>
x
PCをあけるとこうなってました。わけわかめですね。ちなみに構成は適当です。
-------------
|x()x[]xxx()|
|[]<><>[]xxx|
|xxx()()<><>|
|()xx()[][] |
-------------
実はそれぞれの部品は次のようなより大きなパーツで構成されていたとします。
cpu = []<><>[]
battery = x()x[]xxx()
ssd = xxx()()
memory = ()xx()
fan = [][]
書き換えてみると一気にわかりやすいくなりました(何回も言いますが説明のため適当な図です)。
-------------
| battery |
| cpu xxx|
| ssd <><>|
| memory fan|
-------------
どうでしょうか?最小単位の構成要素で考えるよりも、より上位の概念で考えるほうが理解も設計もしやすいですね。人間の例だと、細胞が集まって臓器を形成するイメージですね。
実装レベルでの交換可能性
さきほどの例をあげます。
-------------
| battery |
| cpu xxx|
| ssd <><>|
| memory fan|
-------------
ここでcpuに様々な企業の製品があるとします。もしA社のCPUしか対応していなければ、このPCはA社のCPUでしか動かないものになります。ユーザー的にはなるべくA社B社C社様々なCPUに対応してくれたほうが、業界全体のコストも下がり末端価格も下がりハッピーです。どうすればこれが可能となるでしょうか?
それは、規格です。要求スペックや想定動作やインターフェースを統一することで、部品を交換可能にします。業界では様々な標準団体が存在し、この規格を規定しています。
自分たちのシステムでは、その規格を決めるのは自分たちですね。オブジェクト指向言語ではクラスはそのままでは交換可能ではないので、インターフェースと呼ばれる仕組みを使って、この交換可能性を担保しています。関数型言語では関数は式ベースであり、式は引数の値の型さえ対応していればデフォルトで交換可能です。
プラクティス 抽象化ポイントをみつける
プラクティスは2つのみです。この2つを実践すると抽象化マスターにぐっと近づきます。
- 依存注入
- ライブラリの固有名詞を使わない
プラクティス1 依存注入
抽象化は依存注入前提のコードで活きていきます。僕はこの依存注入が抽象化を抑える上で一番大事だと思っています。
まず言葉の解説からです。依存注入とはなにかです。依存注入とは「依存を内部で発生させずに明確に注入する」手法です。より具体的には、
- 言語やフレームワークで提供されている標準クラスや関数以外のものが、new演算子などでその場で生まれることを許さない。
- 言語やフレームワークで提供されている標準クラスや関数以外のものが、コンストラクタやメソッド(関数)の引数などの関門以外を通って入ってくることを許さない。JS(TS)では直接
import
やrequire
しない(型情報はOK)。
ことを目的に実行します。依存という言葉が何を指すのかは難しいのですが、一般的に「言語やフレームワークで提供されている標準クラス(関数)以外のもの」を指しているのではと思います(詳しい方がいればコメントで教えて下さい)。「言語やフレームワークで提供されている標準クラス以外のもの」は自分で作ったりライブラリしたりしているクラスや関数のことです。
以下に例を示して、その後にこうすると何が嬉しいのかを説明します。
例1: new演算子を使わない
bad
// Fugaクラスを別ファイルで定義
import { Fuga } from './fuga';
class Hoge {
doHoge(){
// new 演算子を使う
const fuga = new Fuga();
fuga.doFuga();
}
}
good1 コンストラクタに注入
// Fugaクラスを別ファイルで定義
import { Fuga } from './fuga';
class Hoge {
private fuga: Fuga;
constructor(fuga: Fuga){
this.fuga = fuga;
}
doHoge(){
// thisを使う
this.fuga.doFuga();
}
}
good2 メソッドに注入
// Fugaクラスを別ファイルで定義
import { Fuga } from './fuga';
class Hoge {
doHoge(fuga: Fuga){
fuga.doFuga();
}
}
例2: 直接実態をimportしない
bad
class Fuga {}
export const fuga = new Fuga();
// Fugaクラスを別ファイルで定義
import { fuga } from './fuga';
class Hoge {
doHoge(){
// fugaをfugaファイルから直接利用
fuga.doFuga();
}
}
good1 コンストラクタに注入
// Fugaクラスを別ファイルで定義
import { Fuga } from './fuga';
class Hoge {
private fuga: Fuga;
constructor(fuga: Fuga){
this.fuga = fuga;
}
doHoge(){
// thisを使う
this.fuga.doFuga();
}
}
good2 メソッドに注入
// Fugaクラスを別ファイルで定義
import { Fuga } from './fuga';
class Hoge {
doHoge(fuga: Fuga){
fuga.doFuga();
}
}
こうするとなぜ嬉しいのでしょうか?
これはHogeクラスを利用する側からみるとわかりやすいです。badパターンはHogeクラスにFugaクラスがボンドでくっつけられているためHogeクラスを使う側は何もできません。まるでApple製品みたいですね。
const hoge = new Hoge();
hoge.doHoge();
それに対してgoodパターンは外からFugaクラスを操作できます。
const fuga = new Fuga({ something: true }); // fuga初期化時にプロパティ指定も可能。
const hoge = new Hoge(fuga);
hoge.doHoge();
const fuga = new Fuga({ something: true }); // fuga初期化時にプロパティ指定も可能。
const hoge = new Hoge();
hoge.doHoge(fuga);
今の所、Fugaクラスに予め何かプロパティを指定してHogeクラスに渡すぐらいのメリット歯科ありません。しかし実はインターフェースと組み合わせるとかなり強力になります。
インターフェースとはオブジェクト指向言語における抽象化のための仕組みの一つです。インターフェースを備えるとそのクラスは、そのインターフェースのもとで、交換可能になります。
// FugaをimplementしたクラスはdoFugaというメソッドを持たないといけない。
interface Fuga {
doFuga: () => void;
}
doFugaというメソッドを備えたRealFugaクラス。
class RealFuga implements Fuga {
doFuga() {
// 実際の処理
}
}
doFugaというメソッドを備えたTestFugaクラス。
class TestFuga implements Fuga {
doFuga() {
console.log('テスト用');
}
}
RealFugaとTestFugaは両方ともFuga型なのでどちらもgoodパターンのfuga注入に入れることができます。
const realFuga: Fuga = new RealFuga();
const testFuga: Fuga = new TestFuga();
const hoge1 = new Hoge(realFuga);
const hoge2 = new Hoge(testFuga);
hoge1.doHoge();
hoge2.doHoge();
これは繰り返しますがとても強力です。このおかげで
- Fugaの振る舞いをコード上で動的に変更ができる。
- テストようのモッククラスなどを注入しやすくなり、テストしやすい。
です。
ただし、すべての依存をコンストラクタで渡すとなると、依存バケツリレーが発生します。またどこかの段階で結局newしなければいけません。これはかなり大変ですね。この用途でDIコンテナを提供するライブラリが存在します。これは、コンストラクタなどにどんなクラスを注入してほしいか目印をつけることで、ライブラリ側に自動的に注入してもらうものです。その代わりに、注入される側のクラスも目印をつけてDIコンテナに登録する必要があります。
例
インターフェースを設定
export interface Logger {
log: (message: string) => void;
}
インターフェースをimplementsしたクラスを実装。
import { injectable } from 'inversify';
import { Logger } from './loggerInterface';
// 注入可能だよっていう目印
@injectable();
class MyLogger implements Logger {
log(message: string){
console.log(message);
}
}
DIコンテナに登録
import { Container } from 'inversify';
import 'reflect-metadata';
import { Logger } from './loggerInterface';
import { MyLogger } from './myLogger';
const container = new Container();
// Loggerっていう名前でDIコンテナに登録。
container.bind<Logger>('Logger').to(MyLogger);
export { container };
DIコンテナから注入
import { inject } from 'inversify';
import { Logger } from './loggerInterface';
class Hoge {
private logger: Logger;
// Loggerって名前のやつ注入してね
constructor(@inject('Logger') logger: Logger){
this.logger = logger;
}
}
プラクティス2 ライブラリの固有名詞を使わない
依存注入だけでもインターフェースと組み合わせればかなり強力だということがわかりました。さらにもう一歩プラクティスを進めます。
まずは以下の例を見てください。
import { createClient } from 'redis';
class UserService {
/**
* ユーザーの現在の評価を取得。評価計算は重い処理なのでredisにキャッシュさせとく。
*/
getUserEvaluation = async (userId: number): Promise<{evaluation: number}> => {
// redisとコネクションはる
const client = createClient({
url: process.env.REDIS_URL,
});
client.on('error', (err) => logger.error('Redis Client Error',err));
this.client.connect();
// redisを検索
const redisUserEvaluation = client.hGet(userId, 'userEvaluation');
// redisに存在すればそれを返す。
if(redisUserEvaluation){
// redis閉じる
client.quit();
return {
evaluation: redisUserEvaluation
}
}
// ユーザーを取得
const user = await this.userRepository.findUserById({ id: reqUser.id });
// ユーザー評価を計算
const evaluation = user.calcEvaluation();
// redisにキャッシュ
client.hSet(userId, 'userEvaluation', evaluation);
// redis閉じる
client.quit();
return {
evaluation: evaluation
}
};
}
こちらにプラクティス1の依存注入を適応してみます。
// 本当はこのライブラリはRedisClientという型を提供していないが説明のため提供しているようにする。
import { RedisClient } from 'redis';
class UserService {
private client: RedisClient;
constructor(redisClient: RedisClient){
this.client = redisClient;
}
/**
* ユーザーの現在の評価を取得。評価計算は重い処理なのでredisにキャッシュさせとく。
*/
getUserEvaluation = async (userId: number): Promise<{evaluation: number}> => {
// redisとコネクションはる
this.client.on('error', (err) => logger.error('Redis Client Error',err));
this.client.connect();
// redisを検索
const redisUserEvaluation = this.client.hGet(userId, 'userEvaluation');
// redisに存在すればそれを返す。
if(redisUserEvaluation){
// redis閉じる
this.client.quit();
return {
evaluation: redisUserEvaluation
}
}
// ユーザーを取得
const user = await this.userRepository.findUserById({ id: reqUser.id });
// ユーザー評価を計算
const evaluation = user.calcEvaluation();
// redisにキャッシュ
this.client.hSet(userId, 'userEvaluation', evaluation);
// redis閉じる
this.client.quit();
return {
evaluation: evaluation
}
};
}
さらにプラクティス2のライブラリの固有名詞を使わないを適用してみます。
redisという名前を使わないため、代わりにキャッシュサービスという名前のインターフェースを作成します。キャッシュサービスはキーバーリューストアとして使うので、get
とset
がほしいですね。
set: (args: {
key: string;
namespace: string;
value: string;
}) => Promise<void>;
get: (args: { key: string; namespace: string }) => Promise<string | null>;
つぎにこちらのインターフェースを実装したRedisCacheServiceを作成します。毎回コネクションつなげたりクローズしたりしてるので、どうにかしたいところ。
// 本当はこのライブラリはRedisClientという型を提供していないが説明のため提供しているようにする。
import { RedisClient } from 'redis';
import { CacheService } from './cacheServiceInterface';
export class RedisCacheService implements CacheService {
private client: RedisClientType;
constructor() {
this.client = createClient({
url: this.config.get('redis.url'),
});
this.client.on('error', (err) => logger.error('Redis Client Error', err));
}
set = async (args: {
key: string;
namespace: string;
value: string;
}): Promise<void> => {
await this.client.connect();
await this.client.hSet(args.key, args.namespace, args.value);
await this.client.quit();
};
get = async (args: {
key: string;
namespace: string;
}): Promise<string | null> => {
await this.client.connect();
const ret = (await this.client.hGet(args.key, args.namespace)) ?? null;
await this.client.quit();
return ret;
};
}
さいごにユーザーサービスないで依存注入します。
import { CacheService } from './cachServiceInterface';
class UserService {
private cacheService: CacheService;
constructor(cacheService: CacheService){
this.cacheService = CacheService;
}
/**
* ユーザーの現在の評価を取得。評価計算は重い処理なのでCacheServiceにキャッシュさせとく。
*/
getUserEvaluation = async (userId: number): Promise<{evaluation: number}> => {
// CacheServiceを検索
const cachedUserEvaluation = this.cacheService.get(userId, 'userEvaluation');
// CacheServiceに存在すればそれを返す。
if(cachedUserEvaluation){
return {
evaluation: redisUserEvaluation
}
}
// ユーザーを取得
const user = await this.userRepository.findUserById({ id: reqUser.id });
// ユーザー評価を計算
const evaluation = user.calcEvaluation();
// キャッシュする
this.cacheService.set(userId, 'userEvaluation', evaluation);
return {
evaluation: evaluation
}
};
}
ライブラリの固有名詞を使わないようにするだけで、より抽象的に考えられるようになりました。
ユースケース 抽象化パターン
ちょっと力尽きてきたので、ここは軽く説明します。
アルゴリズムを交換可能にする -> Strategyパターン
状態に応じてアルゴリズムのみが変わる -> Stateパターン
ファイルとフォルダみたいな内包構造作りたい -> Compositeパターン
機能クラスと実装クラスにマトリクス構造がある -> Bridgeパターン
より理解を深めたい方は「オブジェクト思考のこころ」という書籍をおすすめします。
Discussion