Как сделать ваш код лаконичным и послушным одновременно?
Вам когда-нибудь встречалось приложение, которое вело себя очевидно странно? Ну, вы знаете, вы нажимаете на кнопку и ничего не происходит. Или экран вдруг чернеет. Или приложение впадает в «странное состояние» и вам приходится перезагружать его, чтобы все снова заработало.
Если у вас был подобный опыт, то вы вероятно стали жертвой определенной формы защитного программирования (defensive programming), которую я бы хотел назвать «параноидальное программирование». Защитник осторожный и рассудительный. Параноик испытывает страх и действует странно. В этой статье я предложу альтернативный подход: Offensive programming .
Бдительный читатель
Как может выглядеть параноидальное программирование? Вот типичный пример на Java
public String badlyImplementedGetData(String urlAsString) {
// Convert the string URL into a real URL
URL url = null;
try {
url = new URL(urlAsString);
} catch (MalformedURLException e) {
logger.error("Malformed URL", e);
}
// Open the connection to the server
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) url.openConnection();
} catch (IOException e) {
logger.error("Could not connect to " + url, e);
}
// Read the data from the connection
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
} catch (Exception e) {
logger.error("Failed to read data from " + url, e);
}
return builder.toString();
}
Этот код просто читает содержимое URL как строку. Неожиданное количество кода для выполнения очень простого задания, но такова Java.
Что не так с этим кодом? Код кажется справляется со всеми вероятными ошибками, которые могут возникнуть, но он делает это ужасным способом: просто игнорируя их и продолжая работу. Такая практика безоговорочно поддерживается Java’s checked exceptions ( абсолютно плохое изобретение), но и в других языках видно похожее поведение.
Что происходит если возникает ошибка:
- Если переданный URL невалидный (например “http//..” вместо “http://…”), то следующая строчка выдаст NullPointerException: connection = (HttpURLConnection) url.openConnection(); В данный момент несчастный разработчик, который получит сообщение об ошибке потерял весь контекст настоящей ошибки и мы даже не знаем какой URL вызвал проблему.
- Если рассматриваемый веб сайт не существует, то ситуация становится намного, намного хуже: метод вернет пустую строку. Почему? Результат StringBuilder builder = new StringBuilder(); по-прежнему будет возвращен из метода.
Некоторые разработчики утверждают, что подобный код хорош, так как наше приложение не упадет. Я бы поспорил, что существуют вещи похуже, которые могут случиться с нашим приложением помимо падения. В данном случае, ошибка просто вызовет неправильное поведение без какого-либо объяснения. Например экран может оставаться пустым, но приложение не выдает ошибки.
Давайте посмотрим на код, написанный по беззащитному методу.
public String getData(String url) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
// Read the data from the connection
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
}
return builder.toString();
}
Оператор throws IOException (необходимый в Java, но не в других известных мне языках) показывает, что данный метод может не отработать и вызывающий метод должен быть готов разобраться с этим.
Этот код более лаконичный и если возникает ошибка, то пользователь и логгер (предположительно) получат правильное сообщение об ошибке.
Урок 1: Не обрабатывайте исключения локально.
Оградительный поток
Тогда как же должны обрабатываться ошибки подобного рода? Чтобы хорошо сделать обработку ошибок, нам необходимо учитывать всю архитектуру нашего приложения. Давайте предположим, что у нас есть приложение, которое периодически обновляет UI с содержимым какого-либо URL.
public static void startTimer() {
Timer timer = new Timer();
timer.scheduleAtFixedRate(timerTask(SERVER_URL), 0, 1000);
}
private static TimerTask timerTask(final String url) {
return new TimerTask() {
@Override
public void run() {
try {
String data = getData(url);
updateUi(data);
} catch (Exception e) {
logger.error("Failed to execute task", e);
}
}
};
}
Это именно тот тип
А что случится если мы этого хотим? Во-первых, существует известная практика оборачивания Java’s checked exceptions в RuntimeExceptions
public static String getData(String urlAsString) {
try {
URL url = new URL(urlAsString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
// Read the data from the connection
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
builder.append(line);
}
}
return builder.toString();
} catch (IOException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
На самом деле, библиотеки были написаны с немного большим смыслом, чем сокрытие этой уродливой фичи языка Java.
Теперь мы можем упростить наш таймер:
public static void startTimer() {
Timer timer = new Timer();
timer.scheduleAtFixedRate(timerTask(SERVER_URL), 0, 1000);
}
private static TimerTask timerTask(final String url) {
return new TimerTask() {
@Override
public void run() {
updateUi(getData(url));
}
};
}
Если мы запустим этот код с ошибочным URL ( или сервер не доступен), все станет достаточно плохо: Мы получим сообщение об ошибке в стандартный поток вывода ошибок и наш таймер умрет.
В этот момент времени одна вещь должна быть очевидна: Этот код повторяет действия вне зависимости от того баг ли это, который вызывает NullPointerException, или же просто сервер сейчас не доступен.
В то время как вторая ситуация нас устраивает, первая может быть не очень: баг, который заставляет наш код падать каждый раз теперь будет мусорить в наш лог ошибок. Может нам будет лучше просто убить таймер?
public static void startTimer() // ...
public static String getData(String urlAsString) // ...
private static TimerTask timerTask(final String url) {
return new TimerTask() {
@Override
public void run() {
try {
String data = getData(url);
updateUi(data);
} catch (IOException e) {
logger.error("Failed to execute task", e);
}
}
};
}
Урок 2: Восстановление не всегда хорошо. Вы должны принимать во внимание ошибки, вызванные средой, например проблемами с сетью, и ошибки, вызванные багами, которые не исчезнут до тех пор, пока кто-то не исправит код.
Вы на самом деле там?
Давайте предположим, что у нас есть класс WorkOrders
с задачами. Каждая задача реализуется кем-то. Мы хотим собрать людей, которые вовлечены в WorkOder
. Вы наверняка встречались с подобным кодом:
public static Set findWorkers(WorkOrder workOrder) {
Set people = new HashSet();
Jobs jobs = workOrder.getJobs();
if (jobs != null) {
List jobList = jobs.getJobs();
if (jobList != null) {
for (Job job : jobList) {
Contact contact = job.getContact();
if (contact != null) {
Email email = contact.getEmail();
if (email != null) {
people.add(email.getText());
}
}
}
}
}
return people;
}
В этом коде мы не особо доверяем тому что происходит, не так ли? Давайте предположим, что нам скормили плохие данные. В этом случае, код радостно проглотит данные и вернет пустой набор. Мы на самом деле не заметим, что данные не соответствуют нашим ожиданиям.
Давайте вычистим код:
public static Set findWorkers(WorkOrder workOrder) {
Set people = new HashSet();
for (Job job : workOrder.getJobs().getJobs()) {
people.add(job.getContact().getEmail().getText());
}
return people;
}
Эй! Куда исчез весь код? Вдруг снова легко обсуждать и понимать код. И если есть проблема со структурой обрабатываемой задачи, наш код радостно сломается и сообщит нам об этом.
Проверка на null является одним из самых коварных источников параноидального программирования и их количество быстро увеличивается. Представьте что вы получили багрепорт с продакшна — код просто свалился с NullPointerException в этом коде.
public String getCustomerName() {
return customer.getName();
}
Люди в стрессе! И что делать? Конечно, вы добавляется еще одну проверку на null.
public String getCustomerName() {
if (customer == null) return null;
return customer.getName();
}
Вы компилируете код и выгружаете его на сервер. Немного погодя, вы получаете другой отчет: null pointer exception в следующем коде.
public String getOrderDescription() {
return getOrderDate() + " " + getCustomerName().substring(0,10) + "...";
}
И так это и начинается — распространение проверки на null во всем коде. Просто пресеките проблему в самом начале: не принимайте nulls.
И кстати, если вы интересуетесь можем ли мы заставить код парсера принимать nulls и оставаться простым, то да-мы можем. Предположим, что в примере с задачами мы получаем данные из XML файла. В этом случае, мой любимый способ решения этой задачи будет таким:
public static Set findWorkers(XmlElement workOrder) {
Set people = new HashSet();
for (XmlElement email : workOrder.findRequiredChildren("jobs", "job", "contact", "email")) {
people.add(email.text());
}
return people;
}
Конечно, это требует более достойной библиотеки чем та что есть в Java сейчас.
Урок 3: Проверки на null прячут ошибки и порождают больше проверок на null.
Заключение
Пытаясь писать защитный код, программисты часто в конечном итоге становятся параноиками — отчаянно накидываясь на все проблемы, которые они видят, вместо того чтобы разобраться с первопричиной. Беззащитная стратегия разрешения коду упасть и исправления источника проблемы сделает ваш код чище и менее подверженным ошибкам.
Сокрытие ошибок ведет к размножению багов. Взрыв приложения, прямо перед вашим носом заставляет вас решать реальную проблему.
За перевод спасибо Ольге Чернопицкой и компании Edison, которая разрабатывает внутреннюю систему тестирования персонала и приложение для учета рабочего времени сотрудников.
Автор: Edison