Builder
Накратко: Builder сглобява сложен обект стъпка по стъпка, с име за всяко поле, проверява го в build() и връща готов неизменим обект.
Проблем
Девет параметъра и две разменени думи
Магазинът праща имейл, когато поръчката тръгне. Писмото има девет полета: до кого, на кой адрес да се отговаря, тема, текст, копие, скрито копие, прикачен файл, дали е важно и дали се следи отварянето.
class Email { private final String to; private final String replyTo; private final String subject; private final String body; private final String cc; private final String bcc; private final String attachment; private final boolean highPriority; private final boolean trackOpens;
public Email(String to, String replyTo, String subject, String body, String cc, String bcc, String attachment, boolean highPriority, boolean 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; }
public void send() { System.out.println("До: " + to); if (replyTo != null) { System.out.println("Отговор до: " + replyTo); } System.out.println("Тема: " + subject); if (attachment != null) { System.out.println("Прикачено: " + attachment); } if (highPriority) { System.out.println("Важно"); } }}Ето как се праща потвърждението за поръчка 1042:
Email 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-ът:
Email.Builder, с метод за всяко поле. Методът връща самия builder, затова извикванията се нижат едно след друго във верига; build(): единственото място, където продуктът се проверява и се прави.
Виж го
Веригата и build()
class Email { private final String to; private final String replyTo; private final String subject; private final String body; private final String cc; private final String bcc; private final String attachment; private final boolean highPriority; private final boolean trackOpens;
private Email(Builder b) { to = b.to; replyTo = b.replyTo; subject = b.subject; body = b.body; cc = b.cc; bcc = b.bcc; attachment = b.attachment; highPriority = b.highPriority; trackOpens = b.trackOpens; }
public boolean highPriority() { return highPriority; }
public void send() { System.out.println("До: " + to); if (replyTo != null) { System.out.println("Отговор до: " + replyTo); } System.out.println("Тема: " + subject); if (attachment != null) { System.out.println("Прикачено: " + attachment); } if (highPriority) { System.out.println("Важно"); } }
static class Builder { private String to; private String replyTo; private String subject; private String body; private String cc; private String bcc; private String attachment; private boolean highPriority; private boolean trackOpens;
public Builder to(String value) { to = value; return this; } public Builder replyTo(String value) { replyTo = value; return this; } public Builder subject(String value) { subject = value; return this; } public Builder body(String value) { body = value; return this; } public Builder cc(String value) { cc = value; return this; } public Builder bcc(String value) { bcc = value; return this; } public Builder attachment(String value) { attachment = value; return this; } public Builder highPriority() { highPriority = true; return this; } public Builder trackOpens() { trackOpens = true; return this; }
public Email build() { if (to == null) { throw new IllegalStateException("Липсва получател"); } if (subject == null) { throw new IllegalStateException("Липсва тема"); } if (body == null) { throw new IllegalStateException("Липсва текст"); } return new Email(this); } }}Builder-ът струва код: всяко поле е написано три пъти: в писмото, в builder-а и в неговия метод. Затова не се прави за всеки клас.
Email.Builder е вложен клас: той вижда частния конструктор на Email, а никой друг не го вижда. Полетата на Email са final.Email email = new Email.Builder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
Email.Builder draft = new Email.Builder() .to("peter@example.com") .body("Пратката е на път.");try { draft.build();} catch (IllegalStateException e) { System.out.println(e.getMessage());}До: maria@example.comТема: Поръчка 1042 е изпратенаПрикачено: invoice-1042.pdfВажноЛипсва темаEmail email = new Email.Builder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
Email.Builder draft = new Email.Builder() .to("peter@example.com") .body("Пратката е на път.");try { draft.build();} catch (IllegalStateException e) { System.out.println(e.getMessage());}Стъпка по стъпка, с думи
- new Email.Builder(): празен чертеж с девет гнезда. Трите задължителни (to, subject, body) са отбелязани.
- .to(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
- Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
- .body(…): и трите задължителни гнезда са пълни.
- Незадължителните полета се пишат само ако трябват. highPriority() е без аргумент: вместо true на осма позиция.
- build() проверява задължителните гнезда и щампова Email. Полетата му са final: обектът вече не се променя.
- Писмото тръгва към Мария, с фактурата, като важно.
- Нов builder, нов празен чертеж. Тук някой е забравил темата.
- draft.build() стига до празното гнездо subject и спира с изключение. Писмо без тема изобщо не се появява.
- Грешката казва какво липсва. Нищо не е изпратено.
Писмото без тема не стига до пощата: build() спира още при сглобяването.
Виж го
Без Builder: какво има езикът
record пише полетата, конструктора и методите за четене, и обектът е неизменим, но девет параметъра пак са девет позиции. Затова честите случаи получават фабричен метод с име (като в урока Factory), а проверката отива в компактния конструктор:record Email(String to, String replyTo, String subject, String body, String cc, String bcc, String attachment, boolean highPriority, boolean trackOpens) { Email { if (to == null || subject == null || body == null) { throw new IllegalArgumentException("Липсва получател, тема или текст"); } }
static Email orderShipped(String to, int orderId, String invoice) { return new Email(to, "support@shop.example", "Поръчка " + orderId + " е изпратена", "Пратката е на път.", null, null, invoice, false, false); }}Email email = Email.orderShipped("maria@example.com", 1042, "invoice-1042.pdf");System.out.println(email.to() + " / " + email.subject() + " / " + email.attachment());maria@example.com / Поръчка 1042 е изпратена / invoice-1042.pdfHttpRequest.newBuilder().uri(…).header(…).build(). Библиотеката Lombok пише Builder вместо теб с анотацията @Builder.Кога Builder още си струва:
- писмото се сглобява на стъпки, в различни части на кода: прикаченият файл се добавя само ако има фактура, копието само за фирми;
- има проверка между полетата: например скритото копие не може да е адресът на получателя;
- полетата са много, повечето са незадължителни, а езикът няма аргументи по име.
Пробвай
Предвиди изхода
Един builder, две извиквания на build(). Какво печата програмата?
Email.Builder builder = new Email.Builder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път.");Email first = builder.build();builder.highPriority();Email second = builder.build();System.out.println(first.highPriority() + " " + second.highPriority());Пробвай
Намери грешката
Магазинът праща писмата за изпратените поръчки в цикъл. Фактура има само първата поръчка:
List<Order> orders = List.of( new Order("maria@example.com", 1042, "invoice-1042.pdf"), new Order("peter@example.com", 1043, null));Email.Builder builder = new Email.Builder().replyTo("support@shop.example");for (Order order : orders) { builder.to(order.email()) .subject("Поръчка " + order.id() + " е изпратена") .body("Пратката е на път."); if (order.invoice() != null) { 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-ът е за едно сглобяване: направи нов в цикъла, при всяко писмо. Тук грешката изтича лични данни: чужда фактура с име и адрес.
Идеята
Кога не ти трябва
Builder удвоява кода на класа. Не си струва, когато:
- полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
- record с фабричен метод за честите случаи стига;
- обектът няма какво да проверява и не се сглобява на стъпки.
Струва си, когато полетата са много и повечето са незадължителни, когато има проверка между тях, или когато обектът се сглобява на стъпки, в различни части на кода.
Упражнение
Пратка за куриер
Реши задачата в редактора си.
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“ и сравни двата варианта. Кой е по-кратък и какво губиш?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове · record, switch и шаблони · Шаблони за дизайн · Factory Method и Simple Factory
- Продължава в: Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн