Към съдържанието
wannacode

Builder

Накратко: Builder сглобява сложен обект стъпка по стъпка, с име за всяко поле, и го проверява в build(); в JavaScript същото често решава обект с опции.

Проблем

Девет параметъра и две разменени думи

Магазинът праща имейл, когато поръчката тръгне. Писмото има девет полета: до кого, на кой адрес да се отговаря, тема, текст, копие, скрито копие, прикачен файл, дали е важно и дали се следи отварянето.

class Email {
constructor(to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens) {
this.to = to;
this.replyTo = replyTo;
this.subject = subject;
this.body = body;
this.cc = cc;
this.bcc = bcc;
this.attachment = attachment;
this.highPriority = highPriority;
this.trackOpens = trackOpens;
}
send() {
console.log(`До: ${this.to}`);
if (this.replyTo) {
console.log(`Отговор до: ${this.replyTo}`);
}
console.log(`Тема: ${this.subject}`);
if (this.attachment) {
console.log(`Прикачено: ${this.attachment}`);
}
if (this.highPriority) {
console.log("Важно");
}
}
}

Ето как се праща потвърждението за поръчка 1042:

const email = new Email("support@shop.example", "maria@example.com", "Поръчка 1042 е изпратена",
"Пратката е на път.", null, null, "invoice-1042.pdf", true, false);
email.send();
Изход
До: support@shop.example
Отговор до: maria@example.com
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
Важно

Писмото отиде до поддръжката на магазина, а Мария не получи нищо. Първите два аргумента са разменени. И двата са текст, затова езикът няма как да разбере. А кое е true и кое false в края? За да разбереш, броиш позиции в конструктора. Два null пазят местата на полета, които това писмо няма.

Всяко ново поле, например езикът на писмото, сменя конструктора и всяко място, което го вика.

Идеята

Builder: по име, на стъпки, с проверка

Обектът не се прави с едно извикване на девет позиции, а на стъпки, всяка с име: .to(…), .subject(…). Стъпките ги приема отделен обект, builder. Той събира стойностите, а накрая build():

  • проверява задължителните полета (тук: получател, тема и текст) и хвърля изключение, ако някое липсва;
  • прави неизменим обект: след това писмото не може да се промени.

Това е шаблонът Builder (строител). Ролите:

  • продуктът: Email, неизменим;
  • builder-ът: EmailBuilder, с метод за всяко поле. Методът връща самия builder, затова извикванията се нижат едно след друго във верига;
  • build(): единственото място, където продуктът се проверява и се прави.
В JavaScript проблемът от първата карта има и по-кратък отговор: обект с опции. Той е в картата „Без Builder“. Builder-ът тук е за случая, когато писмото се сглобява на стъпки, на различни места в кода.

Виж го

Веригата и build()

class Email {
constructor({
to,
replyTo = null,
subject,
body,
cc = null,
bcc = null,
attachment = null,
highPriority = false,
trackOpens = false,
}) {
Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens });
Object.freeze(this);
}
send() {
console.log(`До: ${this.to}`);
if (this.replyTo) {
console.log(`Отговор до: ${this.replyTo}`);
}
console.log(`Тема: ${this.subject}`);
if (this.attachment) {
console.log(`Прикачено: ${this.attachment}`);
}
if (this.highPriority) {
console.log("Важно");
}
}
}
class EmailBuilder {
fields = {};
to(value) {
this.fields.to = value;
return this;
}
replyTo(value) {
this.fields.replyTo = value;
return this;
}
subject(value) {
this.fields.subject = value;
return this;
}
body(value) {
this.fields.body = value;
return this;
}
cc(value) {
this.fields.cc = value;
return this;
}
bcc(value) {
this.fields.bcc = value;
return this;
}
attachment(value) {
this.fields.attachment = value;
return this;
}
highPriority() {
this.fields.highPriority = true;
return this;
}
trackOpens() {
this.fields.trackOpens = true;
return this;
}
build() {
if (this.fields.to === undefined) {
throw new Error("Липсва получател");
}
if (this.fields.subject === undefined) {
throw new Error("Липсва тема");
}
if (this.fields.body === undefined) {
throw new Error("Липсва текст");
}
return new Email(this.fields);
}
}

Builder-ът струва код: всяко поле е написано и в писмото, и в метода на builder-а. Затова не се прави за всеки клас.

Email приема един обект с опции и го разглобява по име, със стойности по подразбиране. Object.freeze(this) забранява промяната след създаването.
const email = new EmailBuilder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
const draft = new EmailBuilder()
.to("peter@example.com")
.body("Пратката е на път.");
try {
draft.build();
} catch (e) {
console.log(e.message);
}
Изход
До: maria@example.com
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
Важно
Липсва тема

const email = new EmailBuilder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
const draft = new EmailBuilder()
.to("peter@example.com")
.body("Пратката е на път.");
try {
draft.build();
} catch (e) {
console.log(e.message);
}
Стъпка по стъпка, с думи
  1. new EmailBuilder(): празен чертеж с девет гнезда. Трите задължителни (to, subject, body) са отбелязани.
  2. .to(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
  3. Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
  4. .body(…): и трите задължителни гнезда са пълни.
  5. Незадължителните полета се пишат само ако трябват. highPriority() е без аргумент: вместо true на осма позиция.
  6. build() проверява задължителните гнезда и щампова Email. Полетата му са Object.freeze: обектът вече не се променя.
  7. Писмото тръгва към Мария, с фактурата, като важно.
  8. Нов builder, нов празен чертеж. Тук някой е забравил темата.
  9. draft.build() стига до празното гнездо subject и спира с изключение. Писмо без тема изобщо не се появява.
  10. Грешката казва какво липсва. Нищо не е изпратено.

Писмото без тема не стига до пощата: build() спира още при сглобяването.

Виж го

Без Builder: какво има езикът

JavaScript го прави с обект с опции: един параметър, разглобен по име, със стойности по подразбиране. Редът на полетата няма значение, а липсващото поле получава стойността си по подразбиране:
class Email {
constructor({
to,
replyTo = null,
subject,
body,
cc = null,
bcc = null,
attachment = null,
highPriority = false,
trackOpens = false,
}) {
if (!to || !subject || !body) {
throw new Error("Липсва получател, тема или текст");
}
Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens });
Object.freeze(this);
}
}
const email = new Email({
to: "maria@example.com",
subject: "Поръчка 1042 е изпратена",
body: "Пратката е на път.",
attachment: "invoice-1042.pdf",
highPriority: true,
});
console.log(email.to, "/", email.subject, "/", email.highPriority);
Изход
maria@example.com / Поръчка 1042 е изпратена / true
Object.freeze спира промените. В модул (файл с import) или с "use strict" присвояването хвърля грешка, а в обикновен скрипт тихо не прави нищо:
email.to = "peter@example.com";
Грешка
TypeError: Cannot assign to read only property 'to' of object '#<Email>'

Кога Builder още си струва:

  • писмото се сглобява на стъпки, в различни части на кода: прикаченият файл се добавя само ако има фактура, копието само за фирми;
  • има проверка между полетата: например скритото копие не може да е адресът на получателя;
  • полетата са много, повечето са незадължителни, а сглобяването трябва да се подаде от едно място на друго, преди да е готово.

Пробвай

Предвиди изхода

Един builder, две извиквания на build(). Какво печата програмата?

const builder = new EmailBuilder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.");
const first = builder.build();
builder.highPriority();
const second = builder.build();
console.log(first.highPriority, second.highPriority);
Отговор

Пробвай

Намери грешката

Магазинът праща писмата за изпратените поръчки в цикъл. Фактура има само първата поръчка:

const orders = [
{ email: "maria@example.com", id: 1042, invoice: "invoice-1042.pdf" },
{ email: "peter@example.com", id: 1043, invoice: null },
];
const builder = new EmailBuilder().replyTo("support@shop.example");
for (const order of orders) {
builder
.to(order.email)
.subject(`Поръчка ${order.id} е изпратена`)
.body("Пратката е на път.");
if (order.invoice) {
builder.attachment(order.invoice);
}
builder.build().send();
}
Изход
До: maria@example.com
Отговор до: support@shop.example
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
До: peter@example.com
Отговор до: support@shop.example
Тема: Поръчка 1043 е изпратена
Прикачено: invoice-1042.pdf

Петър получи фактурата на Мария. На кой ред е грешката?

Избери реда с грешката.

Идеята

Кога не ти трябва

Builder удвоява кода на класа. Не си струва, когато:

  • полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
  • обект с опции върши същото без втори клас;
  • обектът няма какво да проверява и не се сглобява на стъпки.

Струва си, когато полетата са много и повечето са незадължителни, когато има проверка между тях, или когато обектът се сглобява на стъпки, в различни части на кода.

Упражнение

Пратка за куриер

Реши задачата в редактора си.

Builder за пратка. Куриерът иска пратка с получател, телефон, адрес и тегло (задължителни), а по желание: чупливо и наложен платеж. Напиши Shipment и builder за нея. build() проверява, че задължителните полета са зададени и че теглото е над 0 и до 30 кг. Направи пратка за Мария (2.5 кг, чупливо, наложен платеж 42.90 €), отпечатай я и опитай пратка от 45 кг:

Изход
Пратка за Мария, 0888 123 456
Адрес: ул. Шипка 5, Пловдив
Тегло: 2.5 кг, чупливо
Наложен платеж: 42.90 €
Теглото трябва да е над 0 и до 30 кг

Още задачи

  • Поправи цикъла. Поправи програмата от „Намери грешката“ така, че всяко писмо да носи само своята фактура. Адресът за отговор трябва да остане за всички писма.
  • Копие с една промяна. Добави метод, който от готово писмо прави builder със същите стойности (в Java често се казва toBuilder()). Изпрати същото писмо до друг получател.
  • Начинът на езика. Напиши Shipment като в картата „Без Builder“ и сравни двата варианта. Кой е по-кратък и какво губиш?

Тест

Провери се

1. new Email("support@…", "maria@…", …) минава без грешка, а писмото отива другаде. Защо JavaScript не помогна?
2. Какво прави build()?
3. Builder-ът е създаден преди цикъла и се ползва за всяко писмо. Второто писмо получи прикачения файл на първото. Защо?
4. Кога Builder още си струва?
5. Какво в JavaScript решава проблема с деветте позиционни аргумента?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·