Offensive programming: параноидальное, наступательное, атакующее или беззащитное программирование

от автора

Как сделать ваш код лаконичным и послушным одновременно?

Вам когда-нибудь встречалось приложение которое вело себя очевидно странно? Ну вы знаете, вы нажимаете на кнопку и ничего не происходит. Или экран вдруг чернеет. Или приложение впадает в «странное состояние» и вам приходится перезагружать его чтобы все снова заработало.

Если у вас был подобный опыт, то вы вероятно стали жертвой определенной формы защитного программирования (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.

Заключение

Пытаясь писать защитный код, программисты часто в конечном итоге становятся параноиками — отчаянно накидываясь на все проблемы, которые они видят, вместо того чтобы разобраться с первопричиной. Беззащитная стратегия разрешения коду упасть и исправления источника проблемы сделает ваш код чище и менее подверженным ошибкам.

Сокрытие ошибок ведет к размножению багов. Взрыв приложения, прямо перед вашим носом заставляет вас решать реальную проблему.
ссылка на оригинал статьи https://habrahabr.ru/post/314550/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *