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

в 15:33, , рубрики: defensive programming, edisonsoftware, java, Offensive programming, Блог компании Edison, ненормальное программирование, отладка, Программирование, разработка, тестирование, метки: ,

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

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

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

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js