JavaScriptアプリケーションの構築では、ある特定の事前に定義された方法でオブジェクトを作成したり、さまざまな目的に応じて共通クラスを修正し、適合させて再利用したりすることがあります。
このような作業を繰り返し行うのは、もちろん楽しいことではありません。
そこで一役買ってくれるのが、JavaScriptデザインパターンです。
JavaScriptデザインパターンは、JavaScriptの開発で頻出する問題に対し、構造化され、繰り返し利用可能な解決策になります。
この記事では、JavaScriptデザインパターンの概要と、JavaScriptアプリケーションにおける使用方法を説明します。
JavaScriptデザインパターンの概要
JavaScriptデザインパターンは、JavaScriptアプリケーション開発で頻繁に発生する問題への、繰り返し可能なテンプレートによる解決策です。
考え方はシンプルです。世界中のプログラマーは、その黎明期から、アプリケーションを開発する際に繰り返し発生する問題に直面してきました。時が経つにつれ、一部の開発者は、こうした問題の解決のために自身が培った経験や方法を他の開発者のために文書化するようになりました。
多くの開発者がそのドキュメントを参考に、問題解決の効率化に成功し、一般的に受け入れられるようになり、やがて「デザインパターン」と呼ばれるようになりました。
デザインパターンの重要性が広く理解されることで、開発はさらに進み、標準化されました。現代のデザインパターンのほとんどは、定義された構造を持ち、複数のカテゴリに整理され、コンピュータサイエンス関連の学位で独立した分野として教えられるまでになりました。
JavaScriptデザインパターンの種類
最も一般的なJavaScriptデザインパターンをいくつか見てみましょう。
生成
生成に関するデザインパターンは、JavaScriptでの新しいオブジェクトインスタンスの作成や、管理に関連する問題解決を支援します。クラスのオブジェクトを1つに限定する単純なものから、JavaScriptオブジェクトの各機能を逐一選択して追加するような、込み入った方法を定義する複雑なものまであります。
生成に関するデザインパターンの例としては、Singleton、Factory Method、Abstract Factory、Builderなどがあります。
構造
構造に関するデザインパターンは、JavaScriptオブジェクトの構造(またはスキーマ)管理に関連する問題解決を支援します。このような問題には、2つの異なるオブジェクト間の関係の構築や、特定ユーザー向けのオブジェクト機能の抽象化があります。
構造に関するデザインパターンの例としては、Adapter、Bridge、Composite、Facadeなどがあります。
振る舞い
振る舞いに関するデザインパターンは、様々なオブジェクト間で制御(と責任)を受け渡す方法に関連する問題解決を支援します。このような問題には、リンクリストへのアクセスの制御や、複数種類のオブジェクトへのアクセスを制御できる単一エンティティの確立があります。
振る舞いに関するデザインパターンの例としては、Command、Iterator、Memento、Observerなどがあります。
並行
並行に関するデザインパターンは、マルチスレッドやマルチタスクに関連する問題解決を支援します。このような問題には、複数の利用可能なオブジェクトの中でのアクティブオブジェクトの維持や、流入する入力を多重化せず、逐次処理することでシステムで発生する複数のイベント処理があります。
並行に関するデザインパターンの例としては、active object、nuclear react、schedulerなどがあります。
アーキテクチャ
アーキテクチャに関するデザインパターンは、広い意味でのソフトウェア設計に関連する問題解決を支援します。システム設計、高可用性の確保、リスクの軽減、パフォーマンスのボトルネックの回避などがその一例です。
アーキテクチャに関するデザインパターンの例としては、MVCとMVVMの2つがあります。
デザインパターンの要素
ほとんどすべてのデザインパターンは、4つの重要なコンポーネントに分けることができます。
- パターン名:他のユーザーとのコミュニケーション時、デザインパターンの識別に使用(「Singleton」や「Prototype」など)。
- 問題:デザインパターンの目的を記述。デザインパターンが解決しようとしている問題の短い説明です。問題をより明確に説明するシナリオ例を含む場合もあります。また根本の問題の完全な解決のためにデザインパターンが必要とする、条件の一覧も含まれます。
- 解法:クラス、メソッド、インターフェースなどの要素で構成された問題を解決。明確に定義された様々な要素の関係、責任、協調を伴います。
- 結果:パターンがどの程度うまく問題を解決できたかを分析。空間や時間の使い方などとともに、同じ問題を解決する代替アプローチも議論されます。
デザインパターンとその起源についてさらに掘り下げたい方は、MSU(ミシガン州立大学)の簡潔な学習教材を参照してください。
デザインパターンを使用するメリット
デザインパターンには、数々のメリットがあります。
- 試行、テスト済み:問題に対して、試行し、テストされた解法を入手できるため(デザインパターンが問題の記述に適している限り)、別の解決策を探す手間を省くことができます。また基本的なパフォーマンス最適化が行われているため、安心です。
- 理解しやすい:デザインパターンは小さく、シンプル。熟練したプログラマーでなくても、使用するデザインパターンを容易に選択することができます。デザインパターンは汎用性に富み(特定のプログラミング言語に限定されない)、十分な問題解決能力があれば誰でも使用できます。新入りのソフトウェア開発者でも理解することができるため、技術チームの人員採用コストの削減にもつながります。
- 実装が簡単:後ほどご説明しますが、ほとんどのデザインパターンは非常にシンプルです。コード内でのデザインパターンの実装に、複数のプログラミングの概念を知る必要はありません。
- 再利用しやすいコードアーキテクチャの提案:コードの再利用とクリーンさは、技術業界全体で強く推奨されており、デザインパターンはこれに一役買ってくれます。デザインパターンは問題を解決する標準的な方法であるため、パターンの設計者は、アプリケーションアーキテクチャ全体が再利用可能で、柔軟性があり、ほとんどのコード記法と互換性を保つように配慮しています。
- 時間とアプリケーションサイズの削減:標準的な解法に従う大きなメリットは、実装にかかる手間の削減。開発チーム全員がデザインパターンを理解している可能性は高く、実装する際の計画、コミュニケーション、コラボレーションが容易になります。試行し、テストされた解法は、機能の構築中にリソースリークに見舞われたり、遠回りする可能性は低く、時間と空間の両方を効率的に利用することができます。また、ほとんどのプログラミング言語には、IteratorやObserverのように一般的なデザインパターンを実装した標準テンプレートライブラリが用意されています。
おすすめのJavaScriptデザインパターン
デザインパターンの構成とメリットが分かったところで、JavaScriptアプリケーションで最もよく使用されるデザインパターンを実装方法と共にご紹介していきます。
生成に関するデザインパターン
まずは習得しやすい、生成に関するデザインパターンからです。
1. Singleton
Singletonパターンは、ソフトウェア開発業界で最もよく使用されるデザインパターンの1つです。このパターンが解決する問題は、ただ1つだけのクラスインスタンスの保持。これはデータベースハンドラなど、リソースを大量に消費するオブジェクトをインスタンス化する際に有用です。
JavaScriptによる実装例を見てみましょう。
function SingletonFoo() {
let fooInstance = null;
// For our reference, let's create a counter that will track the number of active instances
let count = 0;
function printCount() {
console.log("Number of instances: " + count);
}
function init() {
// For our reference, we'll increase the count by one whenever init() is called
count++;
// Do the initialization of the resource-intensive object here and return it
return {}
}
function createInstance() {
if (fooInstance == null) {
fooInstance = init();
}
return fooInstance;
}
function closeInstance() {
count--;
fooInstance = null;
}
return {
initialize: createInstance,
close: closeInstance,
printCount: printCount
}
}
let foo = SingletonFoo();
foo.printCount() // Prints 0
foo.initialize()
foo.printCount() // Prints 1
foo.initialize()
foo.printCount() // Still prints 1
foo.initialize()
foo.printCount() // Still 1
foo.close()
foo.printCount() // Prints 0
Singletonパターンは十分に目的を果たしますが、デバッグが難しいことでも知られています。なぜなら依存関係が隠蔽され、クラスのインスタンスの初期化や破棄へのアクセスが制限されるためです。
2. Factory Method
Factory Methodパターンもまた、最も一般的なデザインパターンの1つ。通常のコンストラクタを使用せずにオブジェクトを作成するのが目的です。Factory Methodパターンは、作成したいオブジェクトの構成(または記述)を受け取り、新規に作成したオブジェクトを返します。
JavaScriptによる実装例は以下のようになります。
function Factory() {
this.createDog = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "bulldog") {
dog = new Bulldog();
} else if (breed === "golden retriever") {
dog = new GoldenRetriever();
} else if (breed === "german shepherd") {
dog = new GermanShepherd();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("\n\nBreed: " + dog.breed + "\nShedding Level (out of 5): " + dog.sheddingLevel + "\nCoat Length: " + dog.coatLength + "\nCoat Type: " + dog.coatType)
}
return dog;
}
}
function Labrador() {
this.sheddingLevel = 4
this.coatLength = "short"
this.coatType = "double"
}
function Bulldog() {
this.sheddingLevel = 3
this.coatLength = "short"
this.coatType = "smooth"
}
function GoldenRetriever() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function GermanShepherd() {
this.sheddingLevel = 4
this.coatLength = "medium"
this.coatType = "double"
}
function run() {
let dogs = [];
let factory = new Factory();
dogs.push(factory.createDog("labrador"));
dogs.push(factory.createDog("bulldog"));
dogs.push(factory.createDog("golden retriever"));
dogs.push(factory.createDog("german shepherd"));
for (var i = 0, len = dogs.length; i < len; i++) {
dogs[i].printInfo();
}
}
run()
/**
Output:
Breed: labrador
Shedding Level (out of 5): 4
Coat Length: short
Coat Type: double
Breed: bulldog
Shedding Level (out of 5): 3
Coat Length: short
Coat Type: smooth
Breed: golden retriever
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
Breed: german shepherd
Shedding Level (out of 5): 4
Coat Length: medium
Coat Type: double
*/
Factory Methodデザインパターンは、オブジェクトがどのように作成されるかを制御し、素早くオブジェクトを作成する方法と、オブジェクトのプロパティを定義する統一されたインターフェースを提供してくれます。公開メソッドとプロパティが同じである限り、どれほど犬種を追加しても、プログラムは問題なく動作します。
ただし、Factory Methodパターンではクラスが大量に生成され、管理が難しくなる可能性がある点は念頭に置いてください。
3. Abstract Factory
Abstract Factoryパターンは、Factory Methodパターンのレベルを一段階引き上げてくれます。ファクトリは抽象化され、置き換え可能になりますが、呼び出し元の環境は、使用されるファクトリの詳細や内部構造を知りません。呼び出し元の環境が知るのは、すべてのファクトリがインスタンス化アクションを実行する一連の共通メソッドだけです。
先ほどの例を用いた実装例を見てみます。
// A factory to create dogs
function DogFactory() {
// Notice that the create function is now createPet instead of createDog, since we need
// it to be uniform across the other factories that will be used with this
this.createPet = function (breed) {
let dog;
if (breed === "labrador") {
dog = new Labrador();
} else if (breed === "pug") {
dog = new Pug();
}
dog.breed = breed;
dog.printInfo = function () {
console.log("\n\nType: " + dog.type + "\nBreed: " + dog.breed + "\nSize: " + dog.size)
}
return dog;
}
}
// A factory to create cats
function CatFactory() {
this.createPet = function (breed) {
let cat;
if (breed === "ragdoll") {
cat = new Ragdoll();
} else if (breed === "singapura") {
cat = new Singapura();
}
cat.breed = breed;
cat.printInfo = function () {
console.log("\n\nType: " + cat.type + "\nBreed: " + cat.breed + "\nSize: " + cat.size)
}
return cat;
}
}
// Dog and cat breed definitions
function Labrador() {
this.type = "dog"
this.size = "large"
}
function Pug() {
this.type = "dog"
this.size = "small"
}
function Ragdoll() {
this.type = "cat"
this.size = "large"
}
function Singapura() {
this.type = "cat"
this.size = "small"
}
function run() {
let pets = [];
// Initialize the two factories
let catFactory = new CatFactory();
let dogFactory = new DogFactory();
// Create a common petFactory that can produce both cats and dogs
// Set it to produce dogs first
let petFactory = dogFactory;
pets.push(petFactory.createPet("labrador"));
pets.push(petFactory.createPet("pug"));
// Set the petFactory to produce cats
petFactory = catFactory;
pets.push(petFactory.createPet("ragdoll"));
pets.push(petFactory.createPet("singapura"));
for (var i = 0, len = pets.length; i < len; i++) {
pets[i].printInfo();
}
}
run()
/**
Output:
Type: dog
Breed: labrador
Size: large
Type: dog
Breed: pug
Size: small
Type: cat
Breed: ragdoll
Size: large
Type: cat
Breed: singapura
Size: small
*/
Abstract Factoryパターンを使用すると、ファクトリの具象を簡単に交換でき、ファクトリと生成される製品間の統一性が高まります。しかし、新たな種類の製品の導入は、難易度が上がる可能性があります。というのも、新規メソッドやプロパティへ対応するには、複数のクラスを変更しなければなりません。
4. Builder
Builderパターンは、複雑ながら柔軟性の高いJavaScriptデザインパターンです。内部的な情報を抽象化しつつ、オブジェクトの構築方法を完全に制御でき、各機能を1つずつ製品に組み込むことができます。
BuilderデザインパターンとDirectorを組み合わせたピザ作りの例を見てみます。
// Here's the PizzaBuilder (you can also call it the chef)
function PizzaBuilder() {
let base
let sauce
let cheese
let toppings = []
// The definition of pizza is hidden from the customers
function Pizza(base, sauce, cheese, toppings) {
this.base = base
this.sauce = sauce
this.cheese = cheese
this.toppings = toppings
this.printInfo = function() {
console.log("This pizza has " + this.base + " base with " + this.sauce + " sauce "
+ (this.cheese !== undefined ? "with cheese. " : "without cheese. ")
+ (this.toppings.length !== 0 ? "It has the following toppings: " + toppings.toString() : ""))
}
}
// You can request the PizzaBuilder (/chef) to perform any of the following actions on your pizza
return {
addFlatbreadBase: function() {
base = "flatbread"
return this;
},
addTomatoSauce: function() {
sauce = "tomato"
return this;
},
addAlfredoSauce: function() {
sauce = "alfredo"
return this;
},
addCheese: function() {
cheese = "parmesan"
return this;
},
addOlives: function() {
toppings.push("olives")
return this
},
addJalapeno: function() {
toppings.push("jalapeno")
return this
},
cook: function() {
if (base === null){
console.log("Can't make a pizza without a base")
return
}
return new Pizza(base, sauce, cheese, toppings)
}
}
}
// This is the Director for the PizzaBuilder, aka the PizzaShop.
// It contains a list of preset steps that can be used to prepare common pizzas (aka recipes!)
function PizzaShop() {
return {
makePizzaMargherita: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMargherita = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().cook()
return pizzaMargherita
},
makePizzaAlfredo: function() {
pizzaBuilder = new PizzaBuilder()
pizzaAlfredo = pizzaBuilder.addFlatbreadBase().addAlfredoSauce().addCheese().addJalapeno().cook()
return pizzaAlfredo
},
makePizzaMarinara: function() {
pizzaBuilder = new PizzaBuilder()
pizzaMarinara = pizzaBuilder.addFlatbreadBase().addTomatoSauce().addOlives().cook()
return pizzaMarinara
}
}
}
// Here's where the customer can request pizzas from
function run() {
let pizzaShop = new PizzaShop()
// You can ask for one of the popular pizza recipes...
let pizzaMargherita = pizzaShop.makePizzaMargherita()
pizzaMargherita.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives
let pizzaAlfredo = pizzaShop.makePizzaAlfredo()
pizzaAlfredo.printInfo()
// Output: This pizza has flatbread base with alfredo sauce with cheese. It has the following toppings: jalapeno
let pizzaMarinara = pizzaShop.makePizzaMarinara()
pizzaMarinara.printInfo()
// Output: This pizza has flatbread base with tomato sauce without cheese. It has the following toppings: olives
// Or send your custom request directly to the chef!
let chef = PizzaBuilder()
let customPizza = chef.addFlatbreadBase().addTomatoSauce().addCheese().addOlives().addJalapeno().cook()
customPizza.printInfo()
// Output: This pizza has flatbread base with tomato sauce with cheese. It has the following toppings: olives,jalapeno
}
run()
上のPizzaShop
クラスのように、BuilderとDirectorを組み合わせて、製品の標準的なバリエーション(例えば特別なピザのレシピ)構築のために、毎回従わなければならない一連の手順を事前に定義することができます。
このデザインパターンの唯一の問題は、セットアップと維持がかなり複雑な点です。ただし、新機能の追加については、Factory Methodパターンよりも容易です。
5. Prototype
Prototypeデザインパターンは、既存のオブジェクトを複製することで新しいオブジェクトを素早く簡単に作成します。
最初にプロトタイプオブジェクトを作成し、何度か複製して、新規オブジェクトを作成します。直接オブジェクトインスタンスを作成すると、既存オブジェクトのコピーに比べて多くのリソースを消費する場合に有用です。
以下は、Prototypeパターンを使用して、設定されたテンプレートに基づいて新しい文書を作成する例です。
// Defining how a document would look like
function Document() {
this.header = "Acme Co"
this.footer = "For internal use only"
this.pages = 2
this.text = ""
this.addText = function(text) {
this.text += text
}
// Method to help you see the contents of the object
this.printInfo = function() {
console.log("\n\nHeader: " + this.header + "\nFooter: " + this.footer + "\nPages: " + this.pages + "\nText: " + this.text)
}
}
// A protype (or template) for creating new blank documents with boilerplate information
function DocumentPrototype(baseDocument) {
this.baseDocument = baseDocument
// This is where the magic happens. A new document object is created and is assigned the values of the current object
this.clone = function() {
let document = new Document();
document.header = this.baseDocument.header
document.footer = this.baseDocument.footer
document.pages = this.baseDocument.pages
document.text = this.baseDocument.text
return document
}
}
function run() {
// Create a document to use as the base for the prototype
let baseDocument = new Document()
// Make some changes to the prototype
baseDocument.addText("This text was added before cloning and will be common in both documents. ")
let prototype = new DocumentPrototype(baseDocument)
// Create two documents from the prototype
let doc1 = prototype.clone()
let doc2 = prototype.clone()
// Make some changes to both objects
doc1.pages = 3
doc1.addText("This is document 1")
doc2.addText("This is document 2")
// Print their values
doc1.printInfo()
/* Output:
Header: Acme Co
Footer: For internal use only
Pages: 3
Text: This text was added before cloning and will be common in both documents. This is document 1
*/
doc2.printInfo()
/** Output:
Header: Acme Co
Footer: For internal use only
Pages: 2
Text: This text was added before cloning and will be common in both documents. This is document 2
*/
}
run()
Prototypeパターンは、オブジェクトの大部分が同じ値を共有する場合や、完全なオブジェクトの作成に相当のコストがかかる場合に有効です。クラスのインスタンスを数個しか作成しない場合は、不要かもしれません。
構造に関するデザインパターン
構造に関するデザインパターンは、試行され、テストされたクラスの構造化方法を提供し、ビジネスロジックの整理に便利です。あらゆる用途に応じたパターンがあります。
6. Adapter
アプリケーション構築時に発生する一般的な問題として挙げられるのが、互換性のないクラス間での協調です。
これを理解する良い例が、後方互換性のメンテナンスです。あるクラスの新しいバージョンを作成したら、当然、古いバージョンが動作していたすべての場所でスムーズに使用できるようにしたいもの。しかし、古いバージョンの機能で重要なメソッドを削除、更新するなどして破壊的な変更を加えれば、新しいクラスの実行のために、すべてのクライアントを更新しなければなりません。
そこで登場するのが、このAdapterデザインパターンです。
Adapterデザインパターンは、新しいクラスのメソッドやプロパティと古いクラスのメソッドや、プロパティとの間のギャップを埋める抽象化の役割を果たします。古いクラスと同じインターフェースを持ち、古いメソッドを新しいメソッドにマッピングして同様の処理を実行するロジックを含みます。電源プラグのソケットが、米国式プラグと欧州式プラグの間のアダプターとして機能するのに似ています。
以下はその例です。
// Old bot
function Robot() {
this.walk = function(numberOfSteps) {
// code to make the robot walk
console.log("walked " + numberOfSteps + " steps")
}
this.sit = function() {
// code to make the robot sit
console.log("sit")
}
}
// New bot that does not have the walk function anymore
// but instead has functions to control each step independently
function AdvancedRobot(botName) {
// the new bot has a name as well
this.name = botName
this.sit = function() {
// code to make the robot sit
console.log("sit")
}
this.rightStepForward = function() {
// code to take 1 step from right leg forward
console.log("right step forward")
}
this.leftStepForward = function () {
// code to take 1 step from left leg forward
console.log("left step forward")
}
}
function RobotAdapter(botName) {
// No references to the old interfact since that is usually
// phased out of development
const robot = new AdvancedRobot(botName)
// The adapter defines the walk function by using the
// two step controls. You now have room to choose which leg to begin/end with,
// and do something at each step.
this.walk = function(numberOfSteps) {
for (let i=0; i<numberOfSteps; i++) {
if (i % 2 === 0) {
robot.rightStepForward()
} else {
robot.leftStepForward()
}
}
}
this.sit = robot.sit
}
function run() {
let robot = new Robot()
robot.sit()
// Output: sit
robot.walk(5)
// Output: walked 5 steps
robot = new RobotAdapter("my bot")
robot.sit()
// Output: sit
robot.walk(5)
// Output:
// right step forward
// left step forward
// right step forward
// left step forward
// right step forward
}
run()
デメリットはソースコードが複雑になる点です。上記ではすでに2つの異なるクラスをメンテナンスする必要があったところに、さらにもう1つAdapterが追加されています。
7. Bridge
Adapterパターンを拡張したBridgeデザインパターンでは、クラスとクライアントの両方が互換性のないネイティブインターフェースであっても機能するよう、別々のインターフェースを使用することができます。
このパターンは、2つのタイプのオブジェクト間の非常に疎結合なインターフェースの開発に有用です。また、インターフェースとその実装の拡張性を高め、柔軟性を最大化してくれます。
使用例は以下のとおりです。
// The TV and speaker share the same interface
function TV() {
this.increaseVolume = function() {
// logic to increase TV volume
}
this.decreaseVolume = function() {
// logic to decrease TV volume
}
this.mute = function() {
// logic to mute TV audio
}
}
function Speaker() {
this.increaseVolume = function() {
// logic to increase speaker volume
}
this.decreaseVolume = function() {
// logic to decrease speaker volume
}
this.mute() = function() {
// logic to mute speaker audio
}
}
// The two remotes make use of the same common interface
// that supports volume up and volume down features
function SimpleRemote(device) {
this.pressVolumeDownKey = function() {
device.decreaseVolume()
}
this.pressVolumeUpKey = function() {
device.increaseVolume()
}
}
function AdvancedRemote(device) {
this.pressVolumeDownKey = function() {
device.decreaseVolume()
}
this.pressVolumeUpKey = function() {
device.increaseVolume()
}
this.pressMuteKey = function() {
device.mute()
}
}
function run() {
let tv = new TV()
let speaker = new Speaker()
let tvSimpleRemote = new SimpleRemote(tv)
let tvAdvancedRemote = new AdvancedRemote(tv)
let speakerSimpleRemote = new SimpleRemote(speaker)
let speakerAdvancedRemote = new AdvancedRemote(speaker)
// The methods listed in pair below will have the same effect
// on their target devices
tvSimpleRemote.pressVolumeDownKey()
tvAdvancedRemote.pressVolumeDownKey()
tvSimpleRemote.pressVolumeUpKey()
tvAdvancedRemote.pressVolumeUpKey()
// The advanced remote has additional functionality
tvAdvancedRemote.pressMuteKey()
speakerSimpleRemote.pressVolumeDownKey()
speakerAdvancedRemote.pressVolumeDownKey()
speakerSimpleRemote.pressVolumeUpKey()
speakerAdvancedRemote.pressVolumeUpKey()
speakerAdvancedRemote.pressMuteKey()
}
すでにお気づきかもしれませんが、Bridgeパターンはコードベースが格段に複雑になります。また実際には、ほとんどのインターフェースが通常1つの実装で完了するため、コードの再利用性にはあまりメリットがありません。
8. Composite
Compositeデザインパターンを使用すると、似たようなオブジェクトやエンティティを簡単に構造化し管理できます。Compositeパターンの背景にある基本的な考え方は、オブジェクトとその論理コンテナは、単一の抽象クラスを使用して表現できるというものです。この抽象クラスは、オブジェクトに関連するデータやメソッドと、コンテナ自身への参照を格納します。
Compositeパターンの使用がもっとも適しているのは、データモデルがツリー構造の場合です。ただし、Compositeパターンを使用するためだけにデータモデルをツリー状に変えないようにしてください。柔軟性が大きく損なわれます。
以下は、EC商品のパッケージングシステムを構築する、Compositeデザインパターンの使用例です。パッケージごとの注文総額も計算できるようになっています。
// A product class, that acts as a Leaf node
function Product(name, price) {
this.name = name
this.price = price
this.getTotalPrice = function() {
return this.price
}
}
// A box class, that acts as a parent/child node
function Box(name) {
this.contents = []
this.name = name
// Helper function to add an item to the box
this.add = function(content){
this.contents.push(content)
}
// Helper function to remove an item from the box
this.remove = function() {
var length = this.contents.length;
for (var i = 0; i < length; i++) {
if (this.contents[i] === child) {
this.contents.splice(i, 1);
return;
}
}
}
// Helper function to get one item from the box
this.getContent = function(position) {
return this.contents[position]
}
// Helper function to get the total count of the items in the box
this.getTotalCount = function() {
return this.contents.length
}
// Helper function to calculate the total price of all items in the box
this.getTotalPrice = function() {
let totalPrice = 0;
for (let i=0; i < this.getTotalCount(); i++){
totalPrice += this.getContent(i).getTotalPrice()
}
return totalPrice
}
}
function run() {
// Let's create some electronics
const mobilePhone = new Product("mobile phone", 1000)
const phoneCase = new Product("phone case", 30)
const screenProtector = new Product("screen protector", 20)
// and some stationery products
const pen = new Product("pen", 2)
const pencil = new Product("pencil", 0.5)
const eraser = new Product("eraser", 0.5)
const stickyNotes = new Product("sticky notes", 10)
// and put them in separate boxes
const electronicsBox = new Box("electronics")
electronicsBox.add(mobilePhone)
electronicsBox.add(phoneCase)
electronicsBox.add(screenProtector)
const stationeryBox = new Box("stationery")
stationeryBox.add(pen)
stationeryBox.add(pencil)
stationeryBox.add(eraser)
stationeryBox.add(stickyNotes)
// and finally, put them into one big box for convenient shipping
const package = new Box('package')
package.add(electronicsBox)
package.add(stationeryBox)
// Here's an easy way to calculate the total order value
console.log("Total order price: USD " + package.getTotalPrice())
// Output: USD 1063
}
run()
Compositeパターンの最大の欠点は、将来コンポーネントインターフェースの変更が非常に難しくなる可能性があることです。インターフェースの設計には、時間と労力がかかるものですが、ツリー状のデータモデルの変更は、より難易度が上がります。
9. Decorator
Decoratorパターンを使用すると、既存のオブジェクトを新規オブジェクト内に包み込んで、機能を追加することができます。例えるなら、包装されたプレゼントをさらに包装紙で何度でも包むようなものです。包装のたびに機能を実装できるため、柔軟性の面でも優れています。
技術的な観点では、パターン内に継承がないため、ビジネスロジック設計の自由度が高くなります。
以下は、Decoratorパターンを使用して、標準のCustomer
クラスに機能を追加する例です。
function Customer(name, age) {
this.name = name
this.age = age
this.printInfo = function() {
console.log("Customer:\nName : " + this.name + " | Age: " + this.age)
}
}
function DecoratedCustomer(customer, location) {
this.customer = customer
this.name = customer.name
this.age = customer.age
this.location = location
this.printInfo = function() {
console.log("Decorated Customer:\nName: " + this.name + " | Age: " + this.age + " | Location: " + this.location)
}
}
function run() {
let customer = new Customer("John", 25)
customer.printInfo()
// Output:
// Customer:
// Name : John | Age: 25
let decoratedCustomer = new DecoratedCustomer(customer, "FL")
decoratedCustomer.printInfo()
// Output:
// Customer:
// Name : John | Age: 25 | Location: FL
}
run()
Decoratorパターンのデメリットは、コードの複雑さです。これは、Decoratorを利用した機能の追加に、標準的なパターンが定義されていないためです。ソフトウェア開発ライフサイクルが終わるときには、統一性のない、あるいは似たようなDecoratorが大量に生成されているかもしれません。
Decoratorを設計する際には、他のDecoratorに論理的に依存しないように注意してください。依存を解消しなければ、将来のDecoratorの削除や再構築でアプリケーションの安定性が損なわれる可能性があります。
10. Facade
多くのアプリケーションのビジネスロジックは、構築が完了する頃には複雑になっています。主要な操作の実行には、複数のオブジェクトやメソッドが関与します。初期化、依存関係、メソッドの正しい実行順序などを管理、維持することはかなり難しく、正しく行われなければエラーにつながります。
Facadeデザインパターンを使用すると、前述の一連の操作を呼び出す環境と、操作の実行に関与するオブジェクトやメソッドとの間を抽象化することができます。この抽象化には、オブジェクトの初期化、依存関係の追跡、その他の重要なアクティビティのロジックが含まれます。呼び出し側の環境は、操作の実行に関する情報を持ちません。このためクライアントに対して破壊的な変更を加えることなく、自由にロジックを更新できます。
以下は、アプリケーションでのFacadeパターンの使用例です。
/**
* Let's say you're trying to build an online store. It will have multiple components and
* complex business logic. In the example below, you will find a tiny segment of an online
* store composed together using the Facade design pattern. The various manager and helper
* classes are defined first of all.
*/
function CartManager() {
this.getItems = function() {
// logic to return items
return []
}
this.clearCart = function() {
// logic to clear cart
}
}
function InvoiceManager() {
this.createInvoice = function(items) {
// logic to create invoice
return {}
}
this.notifyCustomerOfFailure = function(invoice) {
// logic to notify customer
}
this.updateInvoicePaymentDetails = function(paymentResult) {
// logic to update invoice after payment attempt
}
}
function PaymentProcessor() {
this.processPayment = function(invoice) {
// logic to initiate and process payment
return {}
}
}
function WarehouseManager() {
this.prepareForShipping = function(items, invoice) {
// logic to prepare the items to be shipped
}
}
// This is where facade comes in. You create an additional interface on top of your
// existing interfaces to define the business logic clearly. This interface exposes
// very simple, high-level methods for the calling environment.
function OnlineStore() {
this.name = "Online Store"
this.placeOrder = function() {
let cartManager = new CartManager()
let items = cartManager.getItems()
let invoiceManager = new InvoiceManager()
let invoice = invoiceManager.createInvoice(items)
let paymentResult = new PaymentProcessor().processPayment(invoice)
invoiceManager.updateInvoicePaymentDetails(paymentResult)
if (paymentResult.status === 'success') {
new WarehouseManager().prepareForShipping(items, invoice)
cartManager.clearCart()
} else {
invoiceManager.notifyCustomerOfFailure(invoice)
}
}
}
// The calling environment is unaware of what goes on when somebody clicks a button to
// place the order. You can easily change the underlying business logic without breaking
// your calling environment.
function run() {
let onlineStore = new OnlineStore()
onlineStore.placeOrder()
}
Facadeパターンの欠点は、ビジネスロジックとクライアントの間に抽象化レイヤーを挿入するため、追加のメンテナンス作業が必要になることです。多くの場合、コードベースが全体的に複雑になります。
さらに、Facade
クラスはアプリケーションの機能にとって必須の依存関係になります。つまり、Facade
クラスのエラーはアプリケーションの機能に直接影響します。
11. Flyweight
Flyweightパターンは、何度も繰り返し登場するコンポーネントを持つオブジェクトのメモリ問題解決を支援します。オブジェクトプールの共通コンポーネントを再利用することでメモリ効率の向上が期待できます。メモリへの負荷が軽減され、実行時間も速くなります。
以下の例では、Flyweightデザインパターンを使用して、長尺の文章をメモリに格納します。文字を検出するたびにメモリに格納するのではなく、プログラムは、段落を記述する文字の集合とそのタイプ(数字かアルファベット)を識別し、文字ごとに再利用可能なFlyweightオブジェクトを構築して、格納されている文字とタイプの情報を保存します。
メインの配列は、文字オブジェクトのインスタンスを格納せず、文中に文字が出現するたびに、Flyweightオブジェクトへの参照のリストを格納します。
これにより、文章が消費するメモリを半分に減らせます。なお、以下はあくまでもテキストプロセッサのテキスト格納方法に関する基本的な記述になります。
// A simple Character class that stores the value, type, and position of a character
function Character(value, type, position) {
this.value = value
this.type = type
this.position = position
}
// A Flyweight class that stores character value and type combinations
function CharacterFlyweight(value, type) {
this.value = value
this.type = type
}
// A factory to automatically create the flyweights that are not present in the list,
// and also generate a count of the total flyweights in the list
const CharacterFlyweightFactory = (function () {
const flyweights = {}
return {
get: function (value, type) {
if (flyweights[value + type] === undefined)
flyweights[value + type] = new CharacterFlyweight(value, type)
return flyweights[value + type]
},
count: function () {
let count = 0;
for (var f in flyweights) count++;
return count;
}
}
})()
// An enhanced Character class that uses flyweights to store references
// to recurring value and type combinations
function CharacterWithFlyweight(value, type, position) {
this.flyweight = CharacterFlyweightFactory.get(value, type)
this.position = position
}
// A helper function to define the type of a character
// It identifies numbers as N and everything as A (for alphabets)
function getCharacterType(char) {
switch (char) {
case "0":
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9": return "N"
default:
return "A"
}
}
// A list class to create an array of Characters from a given string
function CharactersList(str) {
chars = []
for (let i = 0; i < str.length; i++) {
const char = str[i]
chars.push(new Character(char, getCharacterType(char), i))
}
return chars
}
// A list class to create an array of CharacterWithFlyweights from a given string
function CharactersWithFlyweightsList(str) {
chars = []
for (let i = 0; i < str.length; i++) {
const char = str[i]
chars.push(new CharacterWithFlyweight(char, getCharacterType(char), i))
}
return chars
}
function run() {
// Our input is a large paragraph with over 600 characters
let input = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam et velit pretium, consectetur mauris eu, interdum erat. Aliquam sed nisl turpis. Proin eget urna magna. Nam commodo felis neque, in imperdiet libero dictum vitae. Donec finibus consectetur nibh at blandit. Pellentesque consectetur ipsum metus, ut viverra felis rutrum id. Mauris convallis elit sed ante venenatis mollis. Suspendisse urna libero, dapibus gravida semper viverra, aliquam eu mauris. Integer suscipit bibendum viverra. Suspendisse felis diam, ultrices sit amet ornare id, egestas ut diam. Nulla facilisi. Praesent ullamcorper eros in quam tincidunt, eu tincidunt ipsum imperdiet."
// We store the string into lists of characters and characterWithFlyweights
const charactersList = CharactersList(input)
const charactersWithFlyweightsList = CharactersWithFlyweightsList(input)
// The characters list turns out to be as long as the string, with each object taking
// more than 3 times the size of a primitive character (because of its type and position metadata).
console.log("Character count -> " + charactersList.length)
// Output: Character count -> 656
// The number of flyweights created is only 31, since only
// 31 characters are used to write the entire paragraph.
// This means that to store 656 characters, a total of
// (31 * 2 + 656 * 1 = 718) memory blocks are used instead of
// (656 * 3 = 1968) which would have used by the standard array.
// (We have assumed each variable to take up one memory block for simplicity.
// This may vary in real-life scenarios.)
console.log("Flyweights created -> " + CharacterFlyweightFactory.count())
// Output: Flyweights created -> 31
}
run()
すでにお気づきかもしれませんが、Flyweightパターンは特に直感的でないため、ソフトウェア設計が複雑になります。アプリケーションで特にメモリの削減が必要でない場合は、使用しない方が賢明かもしれません。
さらに、Flyweightパターンはメモリ効率と引き換えに処理効率を下げるため、CPUパワーが不足していると良い解決策になりません。
12. Proxy
Proxyパターンを使用すると、オブジェクトを別のオブジェクトで置き換えることができます。言い換えると、Proxyオブジェクトは、実際のオブジェクトの代わりとなってオブジェクトへのアクセスを制御します。Proxyオブジェクトを使用すると、呼び出しリクエストを実際のオブジェクトに渡す前後で、何らかのアクションを実行することができます。
以下は、Proxyオブジェクトを介してデータベースインスタンスへのアクセスを制御する例です。要求を許可する前に、基本的な検証チェックを行います
function DatabaseHandler() {
const data = {}
this.set = function (key, val) {
data[key] = val;
}
this.get = function (key, val) {
return data[key]
}
this.remove = function (key) {
data[key] = null;
}
}
function DatabaseProxy(databaseInstance) {
this.set = function (key, val) {
if (key === "") {
console.log("Invalid input")
return
}
if (val === undefined) {
console.log("Setting value to undefined not allowed!")
return
}
databaseInstance.set(key, val)
}
this.get = function (key) {
if (databaseInstance.get(key) === null) {
console.log("Element deleted")
}
if (databaseInstance.get(key) === undefined) {
console.log("Element not created")
}
return databaseInstance.get(key)
}
this.remove = function (key) {
if (databaseInstance.get(key) === undefined) {
console.log("Element not added")
return
}
if (databaseInstance.get(key) === null) {
console.log("Element removed already")
return
}
return databaseInstance.remove(key)
}
}
function run() {
let databaseInstance = new DatabaseHandler()
databaseInstance.set("foo", "bar")
databaseInstance.set("foo", undefined)
console.log("#1: " + databaseInstance.get("foo"))
// #1: undefined
console.log("#2: " + databaseInstance.get("baz"))
// #2: undefined
databaseInstance.set("", "something")
databaseInstance.remove("foo")
console.log("#3: " + databaseInstance.get("foo"))
// #3: null
databaseInstance.remove("foo")
// databaseInstance.remove("baz")
// Create a fresh database instance to try the same operations
// using the proxy
databaseInstance = new DatabaseHandler()
let proxy = new DatabaseProxy(databaseInstance)
proxy.set("foo", "bar")
proxy.set("foo", undefined)
// Proxy jumps in:
// Output: Setting value to undefined not allowed!
console.log("#1: " + proxy.get("foo"))
// Original value is retained:
// Output: #1: bar
console.log("#2: " + proxy.get("baz"))
// Proxy jumps in again
// Output:
// Element not created
// #2: undefined
proxy.set("", "something")
// Proxy jumps in again
// Output: Invalid input
proxy.remove("foo")
console.log("#3: " + proxy.get("foo"))
// Proxy jumps in again
// Output:
// Element deleted
// #3: null
proxy.remove("foo")
// Proxy output: Element removed already
proxy.remove("baz")
// Proxy output: Element not added
}
run()
このデザインパターンは、実行前後の処理を簡単に実装できるため、広く使用されています。とは言え、他のデザインパターン同様、コードベースは複雑になるため、本当に必要なときにだけ使用してください。
なお、実際のオブジェクトを呼び出す際に、追加のオブジェクトが関与するため、挿入された処理操作による待ち時間が発生する可能性があります。またメインのオブジェクトのパフォーマンスを最適化する際には、Proxyクラスのメソッドの最適化も必要です。
振る舞いに関するデザインパターン
振る舞いに関するデザインパターンは、オブジェクト同士の対話に関連する問題解決に役立ちます。これには、一連の操作を完了するためのオブジェクト間での責任や制御の共有と、受け渡し、また可能な限り効率的な方法での、複数のオブジェクト間でのデータの受け渡しや共有も含まれます。
13. Chain of Responsibility
Chain of Responsibilityパターンは、振る舞いに関するシンプルなデザインパターンです。複数ハンドラで処理される操作ロジックの設計に使用できます。
カスタマーサポートのエスカレーション対応に似て、制御はハンドラの連鎖で渡され、アクションに対する責任を持つハンドラが操作を実行します。このデザインパターンは、UIデザインでよく使用され、タッチやスワイプなどのユーザー入力イベントをコンポーネントの複数レイヤーで処理します。
Chain of Responsibilityパターンを使って、エスカレーション対応の例を見てみます。顧客からのクレームが重大性に基づき各ハンドラで処理されます。
// Complaint class that stores title and severity of a complaint
// Higher value of severity indicates a more severe complaint
function Complaint (title, severity) {
this.title = title
this.severity = severity
}
// Base level handler that receives all complaints
function Representative () {
// If this handler can not handle the complaint, it will be forwarded to the next level
this.nextLevel = new Management()
this.handleComplaint = function (complaint) {
if (complaint.severity === 0)
console.log("Representative resolved the following complaint: " + complaint.title)
else
this.nextLevel.handleComplaint(complaint)
}
}
// Second level handler to handle complaints of severity 1
function Management() {
// If this handler can not handle the complaint, it will be forwarded to the next level
this.nextLevel = new Leadership()
this.handleComplaint = function (complaint) {
if (complaint.severity === 1)
console.log("Management resolved the following complaint: " + complaint.title)
else
this.nextLevel.handleComplaint(complaint)
}
}
// Highest level handler that handles all complaints unhandled so far
function Leadership() {
this.handleComplaint = function (complaint) {
console.log("Leadership resolved the following complaint: " + complaint.title)
}
}
function run() {
// Create an instance of the base level handler
let customerSupport = new Representative()
// Create multiple complaints of varying severity and pass them to the base handler
let complaint1 = new Complaint("Submit button doesn't work", 0)
customerSupport.handleComplaint(complaint1)
// Output: Representative resolved the following complaint: Submit button doesn't work
let complaint2 = new Complaint("Payment failed", 1)
customerSupport.handleComplaint(complaint2)
// Output: Management resolved the following complaint: Payment failed
let complaint3 = new Complaint("Employee misdemeanour", 2)
customerSupport.handleComplaint(complaint3)
// Output: Leadership resolved the following complaint: Employee misdemeanour
}
run()
この設計の大きな問題点は、処理が一直線であること。大量のハンドラが連鎖していると、操作の処理に遅延が生じる可能性があります。
また、ハンドラの数がある程度増えると、ハンドラの追跡、そしてデバッグも大変になります。各リクエストが異なるハンドラで処理されるため、ログとデバッグの標準化は難しくなります。
14. Iterator
Iteratorパターンは非常にシンプルで、今日のほぼすべてのオブジェクト指向言語で使用されています。すべてが同じ型ではないオブジェクトのリストを処理する場合、forループのような通常の反復メソッドでは非常に面倒になります。内部にビジネスロジックを記述している場合はなおさらです。
Iteratorパターンを使用すると、メインのビジネスロジックからリストの反復処理や処理ロジックを切り離すことができます。
以下、複数タイプの要素を持つシンプルなリストで使用方法を見てみましょう。
// Iterator for a complex list with custom methods
function Iterator(list) {
this.list = list
this.index = 0
// Fetch the current element
this.current = function() {
return this.list[this.index]
}
// Fetch the next element in the list
this.next = function() {
return this.list[this.index++]
}
// Check if there is another element in the list
this.hasNext = function() {
return this.index < this.list.length
}
// Reset the index to point to the initial element
this.resetIndex = function() {
this.index = 0
}
// Run a forEach loop over the list
this.forEach = function(callback) {
for (let element = this.next(); this.index <= this.list.length; element = this.next()) {
callback(element)
}
}
}
function run() {
// A complex list with elements of multiple data types
let list = ["Lorem ipsum", 9, ["lorem ipsum dolor", true], false]
// Create an instance of the iterator and pass it the list
let iterator = new Iterator(list)
// Log the first element
console.log(iterator.current())
// Output: Lorem ipsum
// Print all elements of the list using the iterator's methods
while (iterator.hasNext()) {
console.log(iterator.next())
/**
* Output:
* Lorem ipsum
* 9
* [ 'lorem ipsum dolor', true ]
* false
*/
}
// Reset the iterator's index to the first element
iterator.resetIndex()
// Use the custom iterator to pass an effect that will run for each element of the list
iterator.forEach(function (element) {
console.log(element)
})
/**
* Output:
* Lorem ipsum
* 9
* [ 'lorem ipsum dolor', true ]
* false
*/
}
run()
言うまでもありませんが、リスト内の要素が1種類であれば、このパターンは不必要に複雑です。逆に要素の種類が多すぎても管理が大変になります。
Iteratorパターンを使用する前には、リストと将来的な変更を吟味し、本当に必要かどうかを見極めることが重要です。さらにこのパターンはリストでのみ有効ですが、リストは線形アクセスに縛られます。他のデータ構造を使用した方が、パフォーマンスが向上するかもしれません。
15. Mediator
アプリケーションの設計では、大量のオブジェクトを扱わなければならない場合があります。オブジェクトには様々な種類のビジネスロジックが格納され、依存し合っています。こうした依存関係の処理は、オブジェクト同士のデータや制御の交換方法を追跡しなければならないため、時として非常に厄介です。
Mediatorデザインパターンは、オブジェクト間の対話ロジックを個別のオブジェクトに分離することで、この問題を解決してくれます。
この分離されたオブジェクトはMediatorと呼ばれ、低レベルのクラスが行う作業に責任を持ちます。クライアントや呼び出し元環境も、低レベルクラスの代わりにMediatorと対話します。
Mediatorデザインパターンの例は、以下の通りです。
// Writer class that receives an assignment, writes it in 2 seconds, and marks it as finished
function Writer(name, manager) {
// Reference to the manager, writer's name, and a busy flag that the manager uses while assigning the article
this.manager = manager
this.name = name
this.busy = false
this.startWriting = function (assignment) {
console.log(this.name + " started writing \"" + assignment + "\"")
this.assignment = assignment
this.busy = true
// 2 s timer to replicate manual action
setTimeout(() => { this.finishWriting() }, 2000)
}
this.finishWriting = function () {
if (this.busy === true) {
console.log(this.name + " finished writing \"" + this.assignment + "\"")
this.busy = false
return this.manager.notifyWritingComplete(this.assignment)
} else {
console.log(this.name + " is not writing any article")
}
}
}
// Editor class that receives an assignment, edits it in 3 seconds, and marks it as finished
function Editor(name, manager) {
// Reference to the manager, editor's name, and a busy flag that the manager uses while assigning the article
this.manager = manager
this.name = name
this.busy = false
this.startEditing = function (assignment) {
console.log(this.name + " started editing \"" + assignment + "\"")
this.assignment = assignment
this.busy = true
// 3 s timer to replicate manual action
setTimeout(() => { this.finishEditing() }, 3000)
}
this.finishEditing = function () {
if (this.busy === true) {
console.log(this.name + " finished editing \"" + this.assignment + "\"")
this.manager.notifyEditingComplete(this.assignment)
this.busy = false
} else {
console.log(this.name + " is not editing any article")
}
}
}
// The mediator class
function Manager() {
// Store arrays of workers
this.editors = []
this.writers = []
this.setEditors = function (editors) {
this.editors = editors
}
this.setWriters = function (writers) {
this.writers = writers
}
// Manager receives new assignments via this method
this.notifyNewAssignment = function (assignment) {
let availableWriter = this.writers.find(function (writer) {
return writer.busy === false
})
availableWriter.startWriting(assignment)
return availableWriter
}
// Writers call this method to notify they're done writing
this.notifyWritingComplete = function (assignment) {
let availableEditor = this.editors.find(function (editor) {
return editor.busy === false
})
availableEditor.startEditing(assignment)
return availableEditor
}
// Editors call this method to notify they're done editing
this.notifyEditingComplete = function (assignment) {
console.log("\"" + assignment + "\" is ready to publish")
}
}
function run() {
// Create a manager
let manager = new Manager()
// Create workers
let editors = [
new Editor("Ed", manager),
new Editor("Phil", manager),
]
let writers = [
new Writer("Michael", manager),
new Writer("Rick", manager),
]
// Attach workers to manager
manager.setEditors(editors)
manager.setWriters(writers)
// Send two assignments to manager
manager.notifyNewAssignment("var vs let in JavaScript")
manager.notifyNewAssignment("JS promises")
/**
* Output:
* Michael started writing "var vs let in JavaScript"
* Rick started writing "JS promises"
*
* After 2s, output:
* Michael finished writing "var vs let in JavaScript"
* Ed started editing "var vs let in JavaScript"
* Rick finished writing "JS promises"
* Phil started editing "JS promises"
*
* After 3s, output:
* Ed finished editing "var vs let in JavaScript"
* "var vs let in JavaScript" is ready to publish
* Phil finished editing "JS promises"
* "JS promises" is ready to publish
*/
}
run()
Mediatorパターンは、アプリケーションの設計に分離と大きな柔軟性をもたらしますが、別のクラスを維持しなければなりません。不必要な複雑化を避けるため、実際にコードを書く前に、Mediatorパターンを使用するメリットがあるかどうか検討しましょう。
また、Mediatorクラスは直接ビジネスロジックを持たないとはいえ、アプリケーションの機能に不可欠なコードを多く含むため、すぐに複雑になる点にも注意が必要です。
16. Memento
オブジェクトのバージョン管理もまた、アプリケーション開発時に直面する一般的な問題です。オブジェクトの履歴を保持し、簡単にロールバックをサポートし、更にはそのロールバックを元に戻したいケースは多数あります。しかしこうしたアプリケーションのロジックを書くのは大変です。
そんなときに活用したいのが、Mementoデザインパターンです。
Mementoは、ある時点でのオブジェクトのスナップショットと言えます。Mementoデザインパターンは、このMementoを利用して、時間の経過とともに変化するオブジェクトのスナップショットを保存します。古いバージョンにロールバックする必要があるときは、そのMementoを引き出すことで実現可能です。
テキスト処理アプリケーションでの実装例を見てみます。
// The memento class that can hold one snapshot of the Originator class - document
function Text(contents) {
// Contents of the document
this.contents = contents
// Accessor function for contents
this.getContents = function () {
return this.contents
}
// Helper function to calculate word count for the current document
this.getWordCount = function () {
return this.contents.length
}
}
// The originator class that holds the latest version of the document
function Document(contents) {
// Holder for the memento, i.e., the text of the document
this.text = new Text(contents)
// Function to save new contents as a memento
this.save = function (contents) {
this.text = new Text(contents)
return this.text
}
// Function to revert to an older version of the text using a memento
this.restore = function (text) {
this.text = new Text(text.getContents())
}
// Helper function to get the current memento
this.getText = function () {
return this.text
}
// Helper function to get the word count of the current document
this.getWordCount = function () {
return this.text.getWordCount()
}
}
// The caretaker class that providers helper functions to modify the document
function DocumentManager(document) {
// Holder for the originator, i.e., the document
this.document = document
// Array to maintain a list of mementos
this.history = []
// Add the initial state of the document as the first version of the document
this.history.push(document.getText())
// Helper function to get the current contents of the documents
this.getContents = function () {
return this.document.getText().getContents()
}
// Helper function to get the total number of versions available for the document
this.getVersionCount = function () {
return this.history.length
}
// Helper function to get the complete history of the document
this.getHistory = function () {
return this.history.map(function (element) {
return element.getContents()
})
}
// Function to overwrite the contents of the document
this.overwrite = function (contents) {
let newVersion = this.document.save(contents)
this.history.push(newVersion)
}
// Function to append new content to the existing contents of the document
this.append = function (contents) {
let currentVersion = this.history[this.history.length - 1]
let newVersion
if (currentVersion === undefined)
newVersion = this.document.save(contents)
else
newVersion = this.document.save(currentVersion.getContents() + contents)
this.history.push(newVersion)
}
// Function to delete all the contents of the document
this.delete = function () {
this.history.push(this.document.save(""))
}
// Function to get a particular version of the document
this.getVersion = function (versionNumber) {
return this.history[versionNumber - 1]
}
// Function to undo the last change
this.undo = function () {
let previousVersion = this.history[this.history.length - 2]
this.document.restore(previousVersion)
this.history.push(previousVersion)
}
// Function to revert the document to a previous version
this.revertToVersion = function (version) {
let previousVersion = this.history[version - 1]
this.document.restore(previousVersion)
this.history.push(previousVersion)
}
// Helper function to get the total word count of the document
this.getWordCount = function () {
return this.document.getWordCount()
}
}
function run() {
// Create a document
let blogPost = new Document("")
// Create a caretaker for the document
let blogPostManager = new DocumentManager(blogPost)
// Change #1: Add some text
blogPostManager.append("Hello World!")
console.log(blogPostManager.getContents())
// Output: Hello World!
// Change #2: Add some more text
blogPostManager.append(" This is the second entry in the document")
console.log(blogPostManager.getContents())
// Output: Hello World! This is the second entry in the document
// Change #3: Overwrite the document with some new text
blogPostManager.overwrite("This entry overwrites everything in the document")
console.log(blogPostManager.getContents())
// Output: This entry overwrites everything in the document
// Change #4: Delete the contents of the document
blogPostManager.delete()
console.log(blogPostManager.getContents())
// Empty output
// Get an old version of the document
console.log(blogPostManager.getVersion(2).getContents())
// Output: Hello World!
// Change #5: Go back to an old version of the document
blogPostManager.revertToVersion(3)
console.log(blogPostManager.getContents())
// Output: Hello World! This is the second entry in the document
// Get the word count of the current document
console.log(blogPostManager.getWordCount())
// Output: 53
// Change #6: Undo the last change
blogPostManager.undo()
console.log(blogPostManager.getContents())
// Empty output
// Get the total number of versions for the document
console.log(blogPostManager.getVersionCount())
// Output: 7
// Get the complete history of the document
console.log(blogPostManager.getHistory())
/**
* Output:
* [
* '',
* 'Hello World!',
* 'Hello World! This is the second entry in the document',
* 'This entry overwrites everything in the document',
* '',
* 'Hello World! This is the second entry in the document',
* ''
* ]
*/
}
run()
Mementoデザインパターンは、オブジェクトの履歴管理に対する画期的な解決策になりますが、大量にリソースを消費します。各Mementoはほぼオブジェクトのコピーのため、適切に使用しなければメモリが急速に肥大化します。
オブジェクトの数が多ければ、そのライフサイクル管理も非常に面倒な作業になります。その上、Originator
クラスとCaretaker
クラスは通常、非常に密接に結合しているため、コードの複雑化という懸念点が介在します。
17. Observer
Observerパターンは、Mediatorとは異なる方法で、複数オブジェクト間の対話の問題を解決してくれます。
各オブジェクトが指定されたMediatorを通して互いに通信する代わりに、Observerパターンではオブジェクトが互いを観察します。オブジェクトは、データや制御を送信する際にイベントを発するように設計され、イベントを「リッスン」している他のオブジェクトは、イベントを受信し、その内容に基づき対話できます。
Observerパターンを使用し、複数の人にニュースレターを配信する例を見てみます
// The newsletter class that can send out posts to its subscribers
function Newsletter() {
// Maintain a list of subscribers
this.subscribers = []
// Subscribe a reader by adding them to the subscribers' list
this.subscribe = function(subscriber) {
this.subscribers.push(subscriber)
}
// Unsubscribe a reader by removing them from the subscribers' list
this.unsubscribe = function(subscriber) {
this.subscribers = this.subscribers.filter(
function (element) {
if (element !== subscriber) return element
}
)
}
// Publish a post by calling the receive function of all subscribers
this.publish = function(post) {
this.subscribers.forEach(function(element) {
element.receiveNewsletter(post)
})
}
}
// The reader class that can subscribe to and receive updates from newsletters
function Reader(name) {
this.name = name
this.receiveNewsletter = function(post) {
console.log("Newsletter received by " + name + "!: " + post)
}
}
function run() {
// Create two readers
let rick = new Reader("ed")
let morty = new Reader("morty")
// Create your newsletter
let newsletter = new Newsletter()
// Subscribe a reader to the newsletter
newsletter.subscribe(rick)
// Publish the first post
newsletter.publish("This is the first of the many posts in this newsletter")
/**
* Output:
* Newsletter received by ed!: This is the first of the many posts in this newsletter
*/
// Subscribe another reader to the newsletter
newsletter.subscribe(morty)
// Publish the second post
newsletter.publish("This is the second of the many posts in this newsletter")
/**
* Output:
* Newsletter received by ed!: This is the second of the many posts in this newsletter
* Newsletter received by morty!: This is the second of the many posts in this newsletter
*/
// Unsubscribe the first reader
newsletter.unsubscribe(rick)
// Publish the third post
newsletter.publish("This is the third of the many posts in this newsletter")
/**
* Output:
* Newsletter received by morty!: This is the third of the many posts in this newsletter
*/
}
run()
Observerパターンは、洗練された方法で制御やデータを受け渡し、特に限られた数の接続を通して、多数の送信者と受信者が互いに対話する状況に適しています。すべてのオブジェクトが1対1で接続する場合、1つのパブリッシャーに対して1つのサブスクライバーしかないため、イベントのパブリッシュとサブスクライブから得られるメリットは失われます(両者が直接交信した方が効率的であるため)。
さらにObserverデザインパターンは、サブスクリプションイベントが適切に処理されなければ、パフォーマンスの問題につながる可能性があります。不要になったにもかかわらず、オブジェクトが他のオブジェクトをサブスクライブし続ければ、そのオブジェクトはガベージコレクションの対象にならず、アプリケーションのメモリ使用量が増加します。
18. State
Stateデザインパターンは、ソフトウェア開発業界全体で最も使用されているデザインパターンの1つです。ReactやAngularのような人気のJavaScriptフレームワークは、Stateパターンに大きく依存しており、データとそのデータに基づいたアプリケーションの振る舞いを管理しています。
簡単に言えば、Stateデザインパターンが有効なケースは、エンティティ(コンポーネント、ページ、アプリケーション、サーバーなど)の明確な状態を定義でき、エンティティが状態の変化に対して、あらかじめ定義されたリアクションを持つ場合です。
例えば、ローン申請プロセスを構築するとします。各ステップを状態として定義できます。
顧客は通常、簡略化された一連の申請の状態(保留、審査中、受理、却下)のみを認識しますが、社内ではさらに細かな段階があるかもしれません。各段階において、申請は個別の担当者に割り当てられ、固有の要件を持ちます。
システムの設計では、ある状態での処理が終了すると次の状態に更新され、次の関連する一連のステップが開始されます。
Stateデザインパターンでタスク管理システムを構築する例をご紹介します。
// Create titles for all states of a task
const STATE_TODO = "TODO"
const STATE_IN_PROGRESS = "IN_PROGRESS"
const STATE_READY_FOR_REVIEW = "READY_FOR_REVIEW"
const STATE_DONE = "DONE"
// Create the task class with a title, assignee, and duration of the task
function Task(title, assignee) {
this.title = title
this.assignee = assignee
// Helper function to update the assignee of the task
this.setAssignee = function (assignee) {
this.assignee = assignee
}
// Function to update the state of the task
this.updateState = function (state) {
switch (state) {
case STATE_TODO:
this.state = new TODO(this)
break
case STATE_IN_PROGRESS:
this.state = new IN_PROGRESS(this)
break
case STATE_READY_FOR_REVIEW:
this.state = new READY_FOR_REVIEW(this)
break
case STATE_DONE:
this.state = new DONE(this)
break
default:
return
}
// Invoke the callback function for the new state after it is set
this.state.onStateSet()
}
// Set the initial state of the task as TODO
this.updateState(STATE_TODO)
}
// TODO state
function TODO(task) {
this.onStateSet = function () {
console.log(task.assignee + " notified about new task \"" + task.title + "\"")
}
}
// IN_PROGRESS state
function IN_PROGRESS(task) {
this.onStateSet = function () {
console.log(task.assignee + " started working on the task \"" + task.title + "\"")
}
}
// READY_FOR_REVIEW state that updates the assignee of the task to be the manager of the developer
// for the review
function READY_FOR_REVIEW(task) {
this.getAssignee = function () {
return "Manager 1"
}
this.onStateSet = function () {
task.setAssignee(this.getAssignee())
console.log(task.assignee + " notified about completed task \"" + task.title + "\"")
}
}
// DONE state that removes the assignee of the task since it is now completed
function DONE(task) {
this.getAssignee = function () {
return ""
}
this.onStateSet = function () {
task.setAssignee(this.getAssignee())
console.log("Task \"" + task.title + "\" completed")
}
}
function run() {
// Create a task
let task1 = new Task("Create a login page", "Developer 1")
// Output: Developer 1 notified about new task "Create a login page"
// Set it to IN_PROGRESS
task1.updateState(STATE_IN_PROGRESS)
// Output: Developer 1 started working on the task "Create a login page"
// Create another task
let task2 = new Task("Create an auth server", "Developer 2")
// Output: Developer 2 notified about new task "Create an auth server"
// Set it to IN_PROGRESS as well
task2.updateState(STATE_IN_PROGRESS)
// Output: Developer 2 started working on the task "Create an auth server"
// Update the states of the tasks until they are done
task2.updateState(STATE_READY_FOR_REVIEW)
// Output: Manager 1 notified about completed task "Create an auth server"
task1.updateState(STATE_READY_FOR_REVIEW)
// Output: Manager 1 notified about completed task "Create a login page"
task1.updateState(STATE_DONE)
// Output: Task "Create a login page" completed
task2.updateState(STATE_DONE)
// Output: Task "Create an auth server" completed
}
run()
Stateパターンは、プロセス内のステップを分離する点においては優れていますが、複数の状態を含む大規模なアプリケーションでは、保守管理が非常に困難になる可能性があります。
さらにプロセス設計上すべての状態に対して、直線的な遷移以上の動きを許可すると、それぞれの移動を個別に処理する必要があるため、コードの記述量が増加します
19. Strategy
Strategyパターンは、Policyパターンとしても知られ、共通のインターフェースを使用してクラスをカプセル化し、自由に交換できることを目的とします。これにより、クライアントとクラスの間の疎結合が維持され、好きなだけ実装を追加することができます。
Strategyパターンは、異なるメソッドやアルゴリズムで同じ処理を行う場合や、大規模なswitchブロックをより理解しやすいコードに置き換える場合に、非常に便利です。
以下、Strategyパターンの例を見てみます
// The strategy class that can encapsulate all hosting providers
function HostingProvider() {
// store the provider
this.provider = ""
// set the provider
this.setProvider = function(provider) {
this.provider = provider
}
// set the website configuration for which each hosting provider would calculate costs
this.setConfiguration = function(configuration) {
this.configuration = configuration
}
// the generic estimate method that calls the provider's unique methods to calculate the costs
this.estimateMonthlyCost = function() {
return this.provider.estimateMonthlyCost(this.configuration)
}
}
// Foo Hosting charges for each second and KB of hosting usage
function FooHosting (){
this.name = "FooHosting"
this.rate = 0.0000027
this.estimateMonthlyCost = function(configuration){
return configuration.duration * configuration.workloadSize * this.rate
}
}
// Bar Hosting charges per minute instead of seconds
function BarHosting (){
this.name = "BarHosting"
this.rate = 0.00018
this.estimateMonthlyCost = function(configuration){
return configuration.duration / 60 * configuration.workloadSize * this.rate
}
}
// Baz Hosting assumes the average workload to be of 10 MB in size
function BazHosting (){
this.name = "BazHosting"
this.rate = 0.032
this.estimateMonthlyCost = function(configuration){
return configuration.duration * this.rate
}
}
function run() {
// Create a website configuration for a website that is up for 24 hours and takes 10 MB of hosting space
let workloadConfiguration = {
duration: 84700,
workloadSize: 10240
}
// Create the hosting provider instances
let fooHosting = new FooHosting()
let barHosting = new BarHosting()
let bazHosting = new BazHosting()
// Create the instance of the strategy class
let hostingProvider = new HostingProvider()
// Set the configuration against which the rates have to be calculated
hostingProvider.setConfiguration(workloadConfiguration)
// Set each provider one by one and print the rates
hostingProvider.setProvider(fooHosting)
console.log("FooHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: FooHosting cost: 2341.7856
hostingProvider.setProvider(barHosting)
console.log("BarHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: BarHosting cost: 2601.9840
hostingProvider.setProvider(bazHosting)
console.log("BarHosting cost: " + hostingProvider.estimateMonthlyCost())
// Output: BarHosting cost: 2710.4000
}
run()
Strategyパターンは、クライアントを大きく変えずに、エンティティの新しいバリエーションを導入できる点において優れています。しかし、実装するバリエーションが少ない場合は不要かもしれません。
またカプセル化によって、各バリエーションの内部ロジックの情報は隠されるため、クライアントはその動作の中身がわかりません
20. Visitor
Visitorパターンは、コードの拡張に役立ちます。
クラスに対して、他のクラスのオブジェクトが現在のクラスのオブジェクトを簡単に変更できるメソッドを提供します。他のオブジェクトが、Placeオブジェクトとも呼ばれる現在のオブジェクトを訪問するか、現在のクラスがVisitorオブジェクトを受け入れると、Placeオブジェクトは各外部オブジェクトの訪問を適切に処理します。
以下がその使用例です
// Visitor class that defines the methods to be called when visiting each place
function Reader(name, cash) {
this.name = name
this.cash = cash
// The visit methods can access the place object and invoke available functions
this.visitBookstore = function(bookstore) {
console.log(this.name + " visited the bookstore and bought a book")
bookstore.purchaseBook(this)
}
this.visitLibrary = function() {
console.log(this.name + " visited the library and read a book")
}
// Helper function to demonstrate a transaction
this.pay = function(amount) {
this.cash -= amount
}
}
// Place class for a library
function Library () {
this.accept = function(reader) {
reader.visitLibrary()
}
}
// Place class for a bookstore that allows purchasing book
function Bookstore () {
this.accept = function(reader) {
reader.visitBookstore(this)
}
this.purchaseBook = function (visitor) {
console.log(visitor.name + " bought a book")
visitor.pay(8)
}
}
function run() {
// Create a reader (the visitor)
let reader = new Reader("Rick", 30)
// Create the places
let booksInc = new Bookstore()
let publicLibrary = new Library()
// The reader visits the library
publicLibrary.accept(reader)
// Output: Rick visited the library and read a book
console.log(reader.name + " has $" + reader.cash)
// Output: Rick has $30
// The reader visits the bookstore
booksInc.accept(reader)
// Output: Rick visited the bookstore and bought a book
console.log(reader.name + " has $" + reader.cash)
// Output: Rick has $22
}
run()
この設計の唯一の欠点として、Placeを追加、変更するたびに、各Visitorクラスを更新しなければなりません。複数のVisitorとPlaceオブジェクトが一緒に存在する場合、保守管理が大変になる可能性があります。
それを除けば、クラスの機能を動的に拡張するのに役立つ優れたパターンです
デザインパターン実装時のベストプラクティス
最も一般的なJavaScriptのデザインパターンを見てきたところで、デザインパターンを実装する際に覚えておきたいヒントをいくつかご紹介します。
本当に解決策になるパターンかどうかを慎重に見極める
これは、ソースコード内でデザインパターンを実装する前に念頭に置きましょう。デザインパターンがすべての悩みを解決してくれるように思えても、一度立ち止まり、本当にそのパターンが必要かどうかを必ず吟味してください。
1つの問題に有効なパターンは複数ありますが、アプローチ、そしてもたらす結果は異なります。デザインパターンを選択する基準は、単にそのパターンが問題を解決してくれるかどうかだけではありません。効率的に問題を解決できるか、またより良いパターンが他にないかも検討するようにしてください。
パターンの実装コストを試算する
デザインパターンは、あらゆるエンジニアリングの問題に対する最善策と思われがちですが、すぐに飛びつくべきではありません。
パターンを使用した結果を予測するのはもちろん、自身の状況も考慮する必要があります。例えば、デザインパターンを理解した上で、その保守管理を行うのに十分な規模の開発チームを確保できているか。創業したばかりの会社で、最小限の開発チームで製品のMVPを早急にリリースしようとしているなら、デザインパターンを利用しない方が得策かもしれません。
デザインパターンの使用は、アプリケーション設計の初期段階で計画しない限り、コードの再利用にはつながりません。様々な段階で無作為にデザインパターンを使用すると、不必要に複雑なアプリケーションアーキテクチャになり、週単位での単純化作業が生じてしまいます。
デザインパターンの有効性は、テストで測れるものではありません。チームの経験と内省で判断してください。十分な時間とリソースを確保できる状況下でこそ、デザインパターンが力を発揮します。
すべての解決策をパターン化しない
もう1つの注意事項は、小さな問題とその解決策を組み合わせてパターン化しないこと。そして事あるごとに決まったデザインパターンを使用しないことです。
同じような問題に遭遇したときのために、効果的な解決策を特定し、記憶に留めておくことは良いことですが、遭遇した問題と過去のそれがまったく同じであるとは限りません。無理に使用すると、適切でない解決策を実装し、リソースを無駄に消費しかねません。
デザインパターンが今日、問題と解決策の組み合わせの代表例として確立しているのは、何百人、何千人ものプログラマーによって、長い時間をかけてテストされ、一般化されてきたためです。発生した問題が、過去に見た問題と似ているからという理由だけで、ペアとなる解決策を実行すると、コードで深刻なエラーが発生する可能性があります。
デザインパターンが役立つ状況
デザインパターンが役立つ状況は、要約すると以下の通りです。すべてのアプリケーション開発に当てはまるわけではありませんが、デザインパターンの使用を検討する際のヒントになるはずです。
- デザインパターンをよく理解している優れた開発者チームがいる。
- ソフトウェア開発ライフサイクルモデルを遵守し、アプリケーションアーキテクチャについて深く議論する余地があり、デザインパターンもその議論の中に出てきたことがある。
- 同じような問題が設計の議論の中で何度も出てきており、適切なデザインパターンを知っている。
- そのデザインパターンを使用して、問題の小さなバリエーションを独自に解決しようとしたことがある。
- デザインパターンを使用しても、コードが複雑化しすぎない。
デザインパターンを使用することで、問題を解決でき、シンプルかつ再利用可能で、モジュール化され、疎結合で、理解しやすいコードを書けるのであれば、デザインパターンを導入すべきでしょう。
最後に、すべてをデザインパターンで実装しないようにしてください。デザインパターンは、あくまで問題解決が目的であり、遵守すべき法律や厳密に従うべきルールではありません。「クリーンかつシンプルで、読みやすく、拡張性のあるコードを書く」というルールや法則に変わりはありません。このルールを満たした上で、デザインパターンで問題を解決できるなら、ぜひ活用してください。
まとめ
JavaScriptデザインパターンは、大勢のプログラマーによって長い時間をかけて確立された、問題を解決するための設計パターンです。コードベースをクリーンで疎結合に保つため、試行錯誤を重ねて生まれた解決策となります。
現在では何百ものデザインパターンが存在し、アプリケーションの構築中に遭遇するあらゆる問題を解決することができます。しかし、すべてのデザインパターンが必ず問題を解決してくれるわけではありません。
他のプログラミングのベストプラクティスと同じように、デザインパターンは問題解決のための提案を目的としています。必ず従わなければならないルールではなく、逆にそのように扱うと、アプリケーションに大きな問題を引き起こす可能性があります。
アプリケーション構築が完了したら、アプリをホストするサーバーが必要です。Kinstaのアプリケーションホスティングは、高速かつ信頼性とセキュリティに優れたソリューションです。専用コントロールパネル「MyKinsta」にログインし、GitHubリポジトリを接続したら、あとは起動するだけ。利用料金は、アプリケーションが使用するリソースに対してのみ発生します。
あなたはどのデザインパターンを使用していますか?今回ご紹介したもの以外のデザインパターンもご存じですか?以下のコメント欄で、ぜひお聞かせください。!
コメントを残す