воскресенье, 21 мая 2017 г.

Object Behavior Must Not Be Configurable

Использование свойств объекта в качестве параметров конфигурации является очень распространенной ошибкой, которую мы продолжаем делать главным образом потому, что наши объекты изменяемы - мы их настраиваем. Мы меняем свое поведение, вставляя в него параметры или даже целые настройки / объекты конфигурации. Должен ли я сказать, что это оскорбительно и неуважительно с философской точки зрения? Я могу, но давайте посмотрим на это с практической точки зрения.

Допустим, есть класс, который должен читать веб-страницу и возвращать ее содержимое:
class Page {
  private final String uri;
  Page(final String address) {
    this.uri = address;
  }
  public String html() throws IOException {
    return IOUtils.toString(
      new URL(this.uri).openStream(),
      "UTF-8"
    );
  }
}
Выглядит просто и прямо, верно? Да, это довольно сплоченный и солидный класс. Вот как мы его используем для чтения содержимого титульной страницы Google:
String html = new Page("http://www.google.com").html();
Все хорошо, пока мы не начнем делать этот класс более мощным. Предположим, мы хотим настроить кодировку. Мы не всегда хотим использовать UTF-8. Мы хотим, чтобы он был настраиваемым. Вот что мы делаем:
class Page {
  private final String uri;
  private final String encoding;
  Page(final String address, final String enc) {
    this.uri = address;
    this.encoding = enc;
  }
  public String html() throws IOException {
    return IOUtils.toString(
      new URL(this.uri).openStream(),
      this.encoding
    );
  }
}
Готово, кодировка инкапсулирована и настраивается. Предположим теперь, что мы хотим изменить поведение класса для ситуации пустой страницы. Если пустая страница загружена, мы хотим вернуть «<html />». Но не всегда. Мы хотим, чтобы это было настраиваемо. Вот что мы делаем:
class Page {
  private final String uri;
  private final String encoding;
  private final boolean alwaysHtml;
  Page(final String address, final String enc,
    final boolean always) {
    this.uri = address;
    this.encoding = enc;
    this.alwaysHtml = always;
  }
  public String html() throws IOException {
    String html = IOUtils.toString(
      new URL(this.uri).openStream(),
      this.encoding
    );
    if (html.isEmpty() && this.alwaysHtml) {
      html = "<html/>";
    }
    return html;
  }
}
Класс становится больше, да? Это здорово, мы хорошие программисты, и наш код должен быть сложным, не так ли? Чем сложнее, тем лучше мы программисты! Я саркастично. Точно нет! Но давайте двигаться дальше. Теперь мы хотим, чтобы наш класс все равно продолжал, даже если кодировка не поддерживается на текущей платформе:
class Page {
  private final String uri;
  private final String encoding;
  private final boolean alwaysHtml;
  private final boolean encodeAnyway;
  Page(final String address, final String enc,
    final boolean always, final boolean encode) {
    this.uri = address;
    this.encoding = enc;
    this.alwaysHtml = always;
    this.encodeAnyway = encode;
  }
  public String html() throws IOException,
  UnsupportedEncodingException {
    final byte[] bytes = IOUtils.toByteArray(
      new URL(this.uri).openStream()
    );
    String html;
    try {
      html = new String(bytes, this.encoding);
    } catch (UnsupportedEncodingException ex) {
      if (!this.encodeAnyway) {
        throw ex;
      }
      html = new String(bytes, "UTF-8")
    }
    if (html.isEmpty() && this.alwaysHtml) {
      html = "<html/>";
    }
    return html;
  }
}
Класс растет и становится все более и более мощным! Теперь пришло время ввести новый класс, который мы назовем PageSettings:

class Page {
  private final String uri;
  private final PageSettings settings;
  Page(final String address, final PageSettings stts) {
    this.uri = address;
    this.settings = stts;
  }
  public String html() throws IOException {
    final byte[] bytes = IOUtils.toByteArray(
      new URL(this.uri).openStream()
    );
    String html;
    try {
      html = new String(bytes, this.settings.getEncoding());
    } catch (UnsupportedEncodingException ex) {
      if (!this.settings.isEncodeAnyway()) {
        throw ex;
      }
      html = new String(bytes, "UTF-8")
    }
    if (html.isEmpty() && this.settings.isAlwaysHtml()) {
      html = "<html/>";
    }
    return html;
  }
}

Класс PageSettings является в основном держателем параметров, без какого-либо поведения. У него есть геттеры, которые дают нам доступ к параметрам: isEncodeAnyway (), isAlwaysHtml () и getEncoding (). Если мы продолжим двигаться в этом направлении, в этом классе может быть несколько дюжин конфигурационных параметров. Это может выглядеть очень удобно и является очень типичной моделью в мире Java. Например, посмотрите на JobConf от Hadoop. Вот как мы будем называть нашу настраиваемую страницу (я предполагаю, что PageSettings неизменен):
String html = new Page(
  "http://www.google.com",
  new PageSettings()
    .withEncoding("ISO_8859_1")
    .withAlwaysHtml(true)
    .withEncodeAnyway(false)
).html();
Однако, как бы удобна она ни выглядела на первый взгляд, этот подход очень ошибочен. Главным образом потому, что это побуждает нас делать большие и несвязные объекты. Они растут в размерах и становятся менее тестируемыми, менее ремонтопригодными и менее читаемыми.

Чтобы это не происходило, я бы предложил здесь простое правило: поведение объекта не должно настраиваться. Или, более технически, инкапсулированные свойства не должны использоваться для изменения поведения объекта.

Свойства объекта существуют только для координации расположения реального объекта, который представляет объект. Uri является координатой, а свойство alwaysHtml boolean является триггером изменения поведения. Видете разницу?

Итак, что же нам делать? Каков правильный дизайн? Мы должны использовать композицию декораторов. Вот как это сделать:
Page page = new NeverEmptyPage(
  new DefaultPage("http://www.google.com")
)
String html = new AlwaysTextPage(
  new TextPage(page, "ISO_8859_1")
  page
).html();
Вот как будет выглядеть наш DefaultPage (да, мне пришлось немного изменить его дизайн):
class DefaultPage implements Page {
  private final String uri;
  DefaultPage(final String address) {
    this.uri = address;
  }
  @Override
  public byte[] html() throws IOException {
    return IOUtils.toByteArray(
      new URL(this.uri).openStream()
    );
  }
}
Как видите, я делаю это для реализации интерфейса интерфейса. Теперь декоратор TextPage, который преобразует массив байтов в текст, используя предоставленную кодировку:
class TextPage {
  private final Page origin;
  private final String encoding;
  TextPage(final Page page, final String enc) {
    this.origin = page;
    this.encoding = enc;
  }
  public String html() throws IOException {
    return new String(
      this.origin.html(),
      this.encoding
    );
  }
}
Теперь NeverEmptyPage:
class NeverEmptyPage implements Page {
  private final Page origin;
  NeverEmptyPage(final Page page) {
    this.origin = page;
  }
  @Override
  public byte[] html() throws IOException {
    byte[] bytes = this.origin.html();
    if (bytes.length == 0) {
      bytes = "<html/>".getBytes();
    }
    return bytes;
  }
}
И, наконец, AlwaysTextPage:
class AlwaysTextPage {
  private final TextPage origin;
  private final Page source;
  AlwaysTextPage(final TextPage page, final Page src) {
    this.origin = page;
    this.source = src;
  }
  public String html() throws IOException {
    String html;
    try {
      html = this.origin.html();
    } catch (UnsupportedEncodingException ex) {
      html = new TextPage(this.source, "UTF-8").html();
    }
    return html;
  }
}
Вы можете сказать, что AlwaysTextPage сделает два вызова инкапсулированного источника, в случае неподдерживаемой кодировки, что приведет к дублированию HTTP-запроса. Это правда, и это по дизайну. Мы не хотим, чтобы этот дублированный HTTP-обратный проход произошел. Давайте представим еще один класс, который будет кешировать страницу, которую можно извлечь (а не потокобезопасную, но сейчас это не важно):
class OncePage implements Page {
  private final Page origin;
  private final AtomicReference<byte[]> cache =
    new AtomicReference<>;
  OncePage(final Page page) {
    this.origin = page;
  }
  @Override
  public byte[] html() throws IOException {
    if (this.cache.get() == null) {
      this.cache.set(this.origin.html());
    }
    return this.cache.get();
  }
}
Теперь наш код должен выглядеть так (обратите внимание, я теперь использую OnePage):
Page page = new NeverEmptyPage(
  new OncePage(
    new DefaultPage("http://www.google.com")
  )
)
String html = new AlwaysTextPage(
  new TextPage(page, "ISO_8859_1")
  "UTF-8"
).html();
На данный момент это, наверное, самая интенсивная для кода статья на этом сайте, но я надеюсь, что она читаема и мне удалось передать эту идею. Теперь у нас есть пять классов, каждый из которых довольно маленький, легко читается и легко использовать повторно.

Просто следуйте правилу: никогда не делайте классы настраиваемыми!

Комментариев нет:

Отправить комментарий