前回の続き。
今回は、新しい機能の実装を見据えたリファクタリングを行います。今まではテキストの形で請求書を出力していましたが、請求書をテキストとHTMLの両方で出力できるようにしたいと思います。そのためには、請求金額等の計算処理と、請求金額等の出力処理を分割する必要があります。このようなリファクタリングをSplit Phase(フェーズ分割)と呼びます。
はじめに、今まで作成してきたstatement関数の本体を抽出し、renderPlainText関数とします。
function renderPlainText(
invoice: Invoice,
plays: { [playID: string]: Play }
): string {
// 本文は省略
}
export function statement(
invoice: Invoice,
plays: { [playID: string]: Play }
): string {
return renderPlainText(invoice, plays);
}
次に、中間状態を保持するstatementDataオブジェクトを用意します。statement関数はこのオブジェクトに計算結果を詰めていくようにリファクタリングします。
// renderPlainTextのシグネチャを変更
function renderPlainText(
data: object,
invoice: Invoice,
plays: { [playID: string]: Play }
): string {
...
// renderPlainTextにstatementDataを渡す
const statementData = {};
return renderPlainText(statementData, invoice, plays);
まずはcustomerをstatementDataに詰めます。せっかくTypeScriptを使っているので、StatementDataはクラスにしましょう。
class StatementData {
customer: string;
}
...
const statementData = new StatementData();
statementData.customer = invoice.customer;
同様に、performancesもStatementDataに追加します。
class StatementData {
customer: string;
performances: Array<Performance>;
}
...
statementData.performances = invoice.performances;
これで、renderPlainTextの中でinvoiceを参照していた部分は全てstatementDataに置き換えられるようになったので、invoiceを引数から削除します。
function renderPlainText(
data: StatementData,
plays: { [playID: string]: Play }
): string {
...
return renderPlainText(statementData, plays);
次に、ちょっと大きめのリファクタリングを行います。元々のデータでは興業(performance)と劇(play)は別々になってますが、請求書の計算をする上ではperformanceとplayがセットになっていると都合が良いです。
そこで、StatementDataではPerformanceがplayをもつようにします。
TypeScript力が足りなくて、歪な型になってしまいましたが、とりあえずこんな感じで。。。
interface PerformanceAndPlay {
playID: string;
audience: number;
play: Play;
}
...
const addPlay = (aPerformance: Performance): PerformanceAndPlay => {
return {
...aPerformance,
play: plays[aPerformance.playID]
};
};
const statementData = new StatementData();
statementData.customer = invoice.customer;
statementData.performances = invoice.performances.map(addPlay);
これによって、playFor関数が不要になります。さらに、renderPlainText関数のplaysパラメータも不要になります。
同様にして、amountFor、volumeCreditsForも置き換え可能です。この辺でTypeScriptの型をどう定義すれば良いかはかなり悩ましいです。。。
色々わちゃわちゃしてしまったので、最終的なリファクタリング結果だけ載せておきます。TypeScriptの型定義周りの学習が必要ですね。
export interface Performance {
playID: string;
audience: number;
}
export interface Invoice {
customer: string;
performances: Array<Performance>;
}
export interface Play {
name: string;
type: string;
}
原著では、createStatementという関数でオブジェクトを組み立てていますが、これはもうクラスのコンストラクタでしょということでクラス化しました。
import { Invoice, Play, Performance } from "./types";
class StatementDataPerformance {
playID: string;
audience: number;
play: Play;
constructor(aPerformance: Performance, play: Play) {
this.playID = aPerformance.playID;
this.audience = aPerformance.audience;
this.play = play;
}
get amount(): number {
let result = 0;
switch (this.play.type) {
case "tragedy":
result = 40000;
if (this.audience > 30) {
result += 1000 * (this.audience - 30);
}
break;
case "comedy":
result = 30000;
if (this.audience > 20) {
result += 10000 + 500 * (this.audience - 20);
}
result += 300 * this.audience;
break;
default:
throw new Error(`unknown type: ${this.play.type}`);
}
return result;
}
get volumeCredits(): number {
let result = 0;
result += Math.max(this.audience - 30, 0);
if (this.play.type === "comedy") {
result += Math.floor(this.audience / 5);
}
return result;
}
}
export class StatementData {
customer: string;
performances: Array<StatementDataPerformance>;
constructor(invoice: Invoice, plays: { [playID: string]: Play }) {
this.customer = invoice.customer;
this.performances = invoice.performances.map(
aPerformance =>
new StatementDataPerformance(aPerformance, plays[aPerformance.playID])
);
}
get totalAmount() {
return this.performances.reduce((total, p) => total + p.amount, 0);
}
get totalVolumeCredits() {
return this.performances.reduce((total, p) => total + p.volumeCredits, 0);
}
}
import { Invoice, Play } from "./types";
import { StatementData } from "./StatementData";
function usd(aNumber: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2
}).format(aNumber / 100);
}
function renderPlainText(data: StatementData): string {
let result = `Statement for ${data.customer}\n`;
for (let perf of data.performances) {
// print line for this order
result += ` ${perf.play.name}: ${usd(perf.amount)} (${
perf.audience
} seats)\n`;
}
result += `Amount owed is ${usd(data.totalAmount)}\n`;
result += `You earned ${data.totalVolumeCredits} credits\n`;
return result;
}
function renderHTML(data: StatementData): string {
let result = `<h1>Statement for ${data.customer}</h1>\n`;
result += "<table>\n";
result += "<tr><th>play</th><th>seats</th><th>cost</th></tr>";
for (let perf of data.performances) {
result += ` <tr><td>${perf.play.name}</td><td>${perf.audience}</td>`;
result += `<td>${usd(perf.amount)}</td></tr>\n`;
}
result += "</table>\n";
result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`;
result += `<p>You earned <em>${data.totalVolumeCredits}</em> credits</p>\n`;
return result;
}
export function statement(
invoice: Invoice,
plays: { [playID: string]: Play }
): string {
return renderPlainText(new StatementData(invoice, plays));
}
export function htmlStatement(
invoice: Invoice,
plays: { [playID: string]: Play }
): string {
return renderHTML(new StatementData(invoice, plays));
}
当初の目標通り、請求書情報の計算処理を分離することで、プレーンテキストとHTMLの出力をいい感じに分離できました。StatementDataについては改善の余地あり、という感じで、次回に続きます。