[Перевод] Как обойтись почти без исключений, заменив их уведомлениями
Здравствуйте, Хабр.
Иногда попадаются статьи, которые хочется перевести просто за имя. Еще интереснее, когда такая статья может пригодиться специалистам по разным языкам, но содержит примеры на Java. Совсем скоро надеемся поделиться с вами нашей новейшей идеей по поводу издания большой книги о Java, а пока предлагаем ознакомиться с публикацией Мартина Фаулера от декабря 2014, которая до сих пор не была переведена на русский язык. Перевод сделан с небольшими сокращениями.
Если вы выполняете валидацию тех или иных данных, то обычно не стоит использовать исключения в качестве сигнала о том, что валидация не пройдена. Здесь я опишу рефакторинг подобного кода с использованием паттерна «Уведомление».
Недавно мне попался на глаза код, выполнявший простейшую валидацию JSON-сообщений. Он выглядел примерно так:
public void check() {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
}
Именно так обычно и выполняется валидация. Вы применяете к данным несколько вариантов проверки (выше приведены лишь несколько полей целого класса). Если хотя бы один этап проверки не пройден, то выбрасывается исключение с сообщением об ошибке.
У меня возникали некоторые проблемы с таким подходом. Во-первых, мне не нравится использовать исключения в подобных случаях. Исключение — это сигнал о некотором экстраординарном событии в рассматриваемом коде. Но если вы подвергаете проверке некий внешний ввод, то допускаете, что вводимые сообщения могут содержать ошибки — то есть, ошибки ожидаемы, и применять в таком случае исключения неправильно.
Вторая проблема с подобным кодом заключается в том, что если он валится после первой же обнаруженной ошибки, то целесообразнее сообщать обо всех ошибках, возникших во вводимых данных, а не только о первой. В таком случае клиент сможет вывести пользователю сразу все ошибки, чтобы тот исправил их за одну операцию, а не заставлять пользователя играть с компьютером в кошки-мышки.
В таких случаях я предпочитаю организовывать отчетность об ошибках валидации при помощи паттерна «Уведомление». Уведомление — это объект, собирающий ошибки, при каждом проваленном акте валидации к уведомлению добавляется очередная ошибка. Метод валидации возвращает уведомление, которое вы затем можете разобрать для выяснения дополнительной информации. Простой пример — следующий код для выполнения проверок.
private void validateNumberOfSeats(Notification note) {
if (numberOfSeats < 1) note.addError("number of seats must be positive");
// другие подобные проверки
}
Затем можно сделать простой вызов вроде aNotification.hasErrors() для реагирования на ошибки, если таковые найдутся. Другие методы в уведомлении могут докапываться до более подробной информации об ошибках.
Когда применять такой рефакторинг
Здесь отмечу, что я не призываю избавиться от исключений во всей вашей базе кода. Исключения — очень удобный способ обработки нештатных ситуаций и изъятия их из основного логического потока. Предлагаемый рефакторинг уместен только в тех случаях, когда результат, сообщаемый при помощи исключения, на деле исключительным не является, а значит, должен обрабатываться основной логикой программы. Валидация, рассматриваемая здесь — как раз такой случай.
Удобное «железное правило», которым стоит пользоваться при реализации исключений, находим в Pragmatic Programmers:
Мы полагаем, что исключения следует лишь эпизодически использовать в рамках нормального хода программы; к ним нужно прибегать именно при исключительных событиях. Предположите, что неперехваченное исключение завершит вашу программу, и задайтесь вопросом: «продолжит ли этот код функционировать, если убрать из него обработчики исключений»? Если ответ отрицательный, то, вероятно, исключения применяются в неисключительных ситуациях.
— Дэйв Томас и Энди Хант
Отсюда важное следствие: решение о том, использовать ли исключения для конкретной задачи, зависит от ее контекста. Так, продолжают Дэйв и Энди, считывание из ненайденного файла может в разных контекстах быть или не быть исключительной ситуацией. Если вы пытаетесь считать файл из хорошо известного местоположения, например /etc/hosts в системе Unix, вполне логично предположить, что файл должен там оказаться, и в противном случае целесообразно выдать исключение. С другой стороны, если вы пытаетесь считать файл, расположенный по пути, введенному пользователем в командную строку, то должны допускать, что файла там не окажется, и использовать иной механизм — такой, который сигнализирует о неисключительной природе ошибки.
Есть случай, в котором было бы разумно использовать исключения при ошибке валидации. Подразумевается ситуация: есть данные, которые уже должны были пройти валидацию на более раннем этапе обработки, но вы хотите вновь сделать такую проверку, чтобы перестраховаться от программных ошибок, из-за которых могли бы проскользнуть какие-то недопустимые данные.
Эта статья рассказывает о замене исключений на уведомления в контексте валидации необработанного ввода. Такая техника пригодится вам и в тех случаях, когда уведомление — более целесообразный вариант, чем исключение, но здесь мы сосредоточимся на варианте с валидацией как наиболее распространенном.
Начало
Пока я не упоминал предметную область, поскольку описывал лишь самую общую структуру кода. Но далее при развитии этого примера потребуется точнее очертить предметную область. Речь пойдет о коде, принимающем в формате JSON сообщения о бронировании мест в театре. Код представляет собой класс запроса о бронировании, заполняемый на основе информации JSON при помощи библиотеки gson.
gson.fromJson(jsonString, BookingRequest.class)
Gson принимает класс, ищет любые поля, удовлетворяющие ключу в JSON-документе, а затем заполняет такие поля.
Данный запрос о бронировании содержит всего два элемента, которые мы будем валидировать: дату мероприятия и количество бронируемых мест
class BookingRequest…
private Integer numberOfSeats;
private String date;
Валидационные операции — такие, которые уже упоминались выше
class BookingRequest…
public void check() {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
}
Создание уведомления
Чтобы использовать уведомление, для него нужно создать специальный объект. Уведомление может быть очень простым, порой оно состоит всего лишь из списка строк.
Уведомление аккумулирует ошибки
List<String> notification = new ArrayList<>();
if (numberOfSeats < 5) notification.add("number of seats too small");
// сделать еще несколько проверок
// затем…
if ( ! notification.isEmpty()) // обработать условие ошибки
Хотя простая идиома списка обеспечивает легковесную реализацию паттерна, я предпочитаю этим не ограничиваться и пишу простой класс.
public class Notification {
private List<String> errors = new ArrayList<>();
public void addError(String message) { errors.add(message); }
public boolean hasErrors() {
return ! errors.isEmpty();
}
…
Применяя реальный класс, я четче выражаю мое намерение — читателю кода не приходится ментально соотносить идиому и ее полное значение.
Раскладываем проверочный метод на части
Первым делом я разделю проверочный метод на две части. Внутренняя часть в итоге будет работать только с уведомлениями и не выдавать никаких исключений. Внешняя часть сохранит актуальное поведение проверочного метода — то есть, будет выдавать исключение при провале валидации.
Для этого я первым делом использую выделение метода необычным образом: вынесу все тело проверочного метода в валидационный метод.
class BookingRequest…
public void check() {
validation();
}
public void validation() {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
}
Затем откорректирую валидационный метод так, чтобы он создавал и возвращал уведомление.
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
return note;
}
Теперь могу проверить уведомление и выдать исключение, если оно содержит ошибки.
class BookingRequest…
public void check() {
if (validation().hasErrors())
throw new IllegalArgumentException(validation().errorMessage());
}
Я сделал валидационный метод публичным, поскольку ожидаю, что большинство вызывающих в перспективе предпочтут использовать именно этот метод, а не проверочный.
Разбивая исходный метод на две части, я отграничиваю валидационную проверку от решения о том, как отреагировать на ошибку.
На данном этапе я еще вообще не трогал поведение кода. Уведомление не будет содержать никаких ошибок, а любые проваленные проверки валидации будут и далее выбрасывать исключения, игнорируя любую новую машинерию, которую я сюда добавлю. Но я готовлю почву, чтобы заменить выдачу исключений на работу с уведомлениями.
Прежде чем приступить к этому, нужно кое-что сказать насчет сообщений об ошибках. При рефакторинге существует правило: избегать изменений в наблюдаемом поведении. В таких ситуациях как эта данное правило немедленно ставит перед нами вопрос: какое поведение является наблюдаемым? Очевидно, выдача корректного исключения будет в определенной мере заметна для внешней программы — но в какой степени для нее актуально сообщение об ошибке? В итоге уведомление аккумулирует множество ошибок и сможет обобщить их в единое сообщение, примерно так:
class Notification…
public String errorMessage() {
return errors.stream()
.collect(Collectors.joining(", "));
}
Но здесь возникнет проблема, если на более высоком уровне выполнение программы завязано на получении сообщения лишь о первой обнаруженной ошибке, и тогда вам потребуется нечто вроде:
class Notification…
public String errorMessage() { return errors.get(0); }
Придется обращать внимание не только на вызывающую функцию, но и на все обработчики исключений, чтобы определить адекватный ответ на данную ситуацию.
Хотя здесь я никоим образом не мог спровоцировать никаких проблем, я обязательно скомпилирую и протестирую этот код, прежде, чем вносить новые изменения.
Валидация числа
Самый очевидный шаг в данном случае — заменить первую валидацию
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
if (date == null) note.addError("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) throw new IllegalArgumentException("number of seats must be positive");
return note;
}
Очевидный шаг, но плохой, поскольку он сломает код. Если передать функции нулевую дату, то код добавит ошибку к уведомлению, но затем сразу же попытается ее разобрать и выдаст исключение нулевого указателя — а нас интересует не это исключение.
Поэтому в данном случае лучше сделать менее прямолинейную, но более эффективную вещь — шаг назад.
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) throw new IllegalArgumentException("number of seats cannot be null");
if (numberOfSeats < 1) note.addError("number of seats must be positive");
return note;
}
Предыдущая проверка — это проверка на нуль, поэтому нам нужна условная конструкция, которая позволила бы не создавать исключение нулевого указателя.
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
if (numberOfSeats == null) note.addError("number of seats cannot be null");
else if (numberOfSeats < 1) note.addError("number of seats must be positive");
return note;
}
Вижу, что следующая проверка затрагивает иное поле. Мало того, что на предыдущем этапе рефакторинга мне придется ввести условную конструкцию — теперь мне кажется, что валидационный метод чрезмерно усложняется, и его можно было бы разложить. Итак, выделяем части, отвечающие за валидацию чисел.
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
validateNumberOfSeats(note);
return note;
}
private void validateNumberOfSeats(Notification note) {
if (numberOfSeats == null) note.addError("number of seats cannot be null");
else if (numberOfSeats < 1) note.addError("number of seats must be positive");
}
Смотрю на выделенную валидацию числа, и ее структура мне не нравится. Не люблю использовать при валидации блоки if-then-else, поскольку так легко может получиться код с чрезмерным количеством вложений. Предпочитаю линейный код, который прекращает работать сразу же, как только выполнение программы становится невозможным – такую точку можно определять при помощи граничного условия. Так я реализую замену вложенных условных конструкций граничными условиями.
class BookingRequest…
private void validateNumberOfSeats(Notification note) {
if (numberOfSeats == null) {
note.addError("number of seats cannot be null");
return;
}
if (numberOfSeats < 1) note.addError("number of seats must be positive");
}
При рефакторинге всегда следует пытаться делать минимальные шаги, чтобы сохранить имеющееся поведение
Мое решение сделать шаг назад играет при рефакторинге ключевую роль. Суть рефакторинга заключается в изменении структуры кода, но таким образом, чтобы выполняемые преобразования не меняли его поведения. Поэтому рефакторинг всегда нужно делать мелкими шажками. Так мы страхуемся от возникновения ошибок, которые могут настигнуть нас в отладчике.
Валидация даты
При валидации даты вновь начинаю с выделения метода:
class BookingRequest…
public Notification validation() {
Notification note = new Notification();
validateDate(note);
validateNumberOfSeats(note);
return note;
}
private void validateDate(Notification note) {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) throw new IllegalArgumentException("date cannot be before today");
}
Когда я использовал автоматизированное выделение метода в моей IDE, результирующий код не включал аргумента уведомления. Поэтому я попытался добавить его вручную.
Теперь давайте пойдем назад при валидации даты:
class BookingRequest…
private void validateDate(Notification note) {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
throw new IllegalArgumentException("Invalid format for date", e);
}
if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
}
На втором этапе возникает осложнение с обработкой ошибки, поскольку в выброшенном исключении есть условное исключение (cause exception). Чтобы его обработать, потребуется изменить уведомление — так, чтобы оно могло принимать такие исключения. Поскольку я как раз на полпути: отказываюсь от выбрасывания исключений и перехожу к работе с уведомлениями — мой код красный. Итак, я откатываюсь назад, чтобы оставить метод validateDate в вышеприведенном виде, и готовлю уведомление к приему условного исключения.
Приступая к изменению уведомления, я добавляю метод addError, принимающий условие, после чего изменяю исходный метод так, чтобы он мог вызывать новый метод.
class Notification…
public void addError(String message) {
addError(message, null);
}
public void addError(String message, Exception e) {
errors.add(message);
}
Таким образом, мы принимаем условное исключение, но игнорируем его. Чтобы где-нибудь его разместить, мне нужно превратить запись об ошибке из простой строки в чуть более сложный объект.
class Notification…
private static class Error {
String message;
Exception cause;
private Error(String message, Exception cause) {
this.message = message;
this.cause = cause;
}
}
Я не люблю неприватные поля в Java, однако поскольку здесь мы имеем дело с приватным внутренним классом, меня все устраивает. Если бы я собирался открывать доступ к этому классу ошибки где-нибудь вне уведомления, то инкапсулировал бы эти поля.
Итак, у меня есть класс. Теперь нужно модифицировать уведомление, чтобы использовать его, а не строку.
class Notification…
private List<Error> errors = new ArrayList<>();
public void addError(String message, Exception e) {
errors.add(new Error(message, e));
}
public String errorMessage() {
return errors.stream()
.map(e -> e.message)
.collect(Collectors.joining(", "));
}
Имея новое уведомление, могу внести изменения и в запрос о бронировании
class BookingRequest…
private void validateDate(Notification note) {
if (date == null) throw new IllegalArgumentException("date is missing");
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
note.addError("Invalid format for date", e);
return;
}
if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
Поскольку я уже в выделенном методе, легко отменить оставшуюся валидацию при помощи команды возврата.
Последнее изменение совсем простое
class BookingRequest…
private void validateDate(Notification note) {
if (date == null) {
note.addError("date is missing");
return;
}
LocalDate parsedDate;
try {
parsedDate = LocalDate.parse(date);
}
catch (DateTimeParseException e) {
note.addError("Invalid format for date", e);
return;
}
if (parsedDate.isBefore(LocalDate.now())) note.addError("date cannot be before today");
}
Вверх по стеку
Теперь, когда у нас есть новый метод, следующая задача — посмотреть, кто вызывает исходный проверочный метод и откорректировать эти элементы, чтобы в дальнейшем они пользовались новым валидационным методом. Здесь придется рассмотреть в более широком контексте, как такая структура вписывается в общую логику приложения, поэтому данная проблема выходит за рамки рассматриваемого здесь рефакторинга. Но наша среднесрочная задача – избавиться от использования исключений, огульно применяемых во всех случаях возможного непрохождения валидации.
Во многих ситуациях это позволит вообще избавиться от проверочного метода — тогда все связанные с ним тесты нужно будет переписать так, чтобы они работали с методом валидации. Кроме того, коррекция тестов может понадобиться для проверки того, правильно ли аккумулируются ошибки в уведомлении.
Фреймворки
Ряд фреймворков обеспечивает возможность валидации с применением паттерна уведомления. В Java это Java Bean Validation и валидация Spring. Эти фреймворки служат своеобразными интерфейсами, инициирующими валидацию и использующими уведомление для сбора ошибок ( Set<ConstraintViolation>
для валидации и Errors
в случае Spring).
Присмотритесь к вашему языку и платформе и проверьте, есть ли там валидационный механизм, использующий уведомления. Детали этого механизма отразятся на ходе рефакторинга, но в целом должно получиться очень похоже.