Создание, анализ ирефакторинг
Скачать 3.16 Mb.
|
140 Глава 7 . Обработка ошибок for(Employee e : employees) { totalPay += e.getPay(); } } Сейчас метод getEmployees может возвращать null , но так ли это необходимо? Если изменить getEmployee так, чтобы метод возвращал пустой список, код станет чище: List for(Employee e : employees) { totalPay += e.getPay(); } К счастью, в Java существует метод Collections.emptyList() , который возвращает заранее определенный неизменяемый список, и мы можем воспользоваться им для своих целей: public List if( .. there are no employees .. ) return Collections.emptyList(); } Такое решение сводит к минимуму вероятность появления NullPointerException , а код становится намного чище . не передавайте null Возвращать null из методов плохо, но передавать null при вызове еще хуже . По возможности избегайте передачи null в своем коде (исключение составляют разве что методы сторонних API, при вызове которых без нее не обойтись) . Следующий пример поясняет, почему не следует передавать null . Возьмем про- стой метод для вычисления метрики по двум точкам: public class MetricsCalculator { public double xProjection(Point p1, Point p2) { return (p2.x — p1.x) * 1.5; } … } Что произойдет, если при вызове будет передан аргумент null ? calculator.xProjection(null, new Point(12, 13)); Конечно, возникнет исключение NullPointerException Как исправить его? Можно создать новый тип исключения и инициировать его в методе: public class MetricsCalculator { public double xProjection(Point p1, Point p2) { if (p1 == null || p2 == null) { 140 Литература 141 throw InvalidArgumentException( "Invalid argument for MetricsCalculator.xProjection"); } return (p2.x — p1.x) * 1.5; } } Стало лучше? Пожалуй, лучше, чем NullPointerException , но вспомните: для In- validArgumentException приходится определять обработчик . Что должен делать этот обработчик? Возьметесь предложить хорошую идею? Существует другая альтернатива: можно воспользоваться набором проверочных директив assert : public class MetricsCalculator { public double xProjection(Point p1, Point p2) { assert p1 != null : "p1 should not be null"; assert p2 != null : "p2 should not be null"; return (p2.x — p1.x) * 1.5; } } Неплохо с точки зрения документирования, но проблема не решена . Если при вызове передать null , произойдет ошибка времени выполнения . В большинстве языков программирования не существует хорошего способа справиться со случайной передачей null с вызывающей стороны . А раз так, разумно запретить передачу null по умолчанию . В этом случае вы будете знать, что присутствие null в списке аргументов свидетельствует о возникшей про- блеме; это будет способствовать уменьшению количества ошибок, сделанных по неосторожности . Заключение Чистый код хорошо читается, но он также должен быть надежным . Эти цели не конфликтуют друг с другом . Чтобы написать надежный и чистый код, следует рассматривать обработку ошибок как отдельную задачу, решаемую независимо от основной логики программы . В зависимости от того, насколько нам это удастся, мы сможем прорабатывать ее реализацию независимо от основной логики про- граммы, а это окажет существенное положительное влияние на удобство сопро- вождения нашего кода . литература [Martin]: Agile Software Development: Principles, Patterns, and Practices, Robert C . Martin, Prentice Hall, 2002 . 141 Границы Джеймс Гренинг Редко когда весь программный код наших систем находится под нашим полным контролем . Иногда нам приходится покупать пакеты сторонних разработчиков или использовать открытый код . В других случаях мы зависим от других групп нашей компании, производящих компоненты или подсистемы для нашего про- екта . И этот внешний код мы должны каким-то образом четко интегрировать со своим кодом . В этой главе рассматриваются приемы и методы «сохранения чистоты» границ нашего программного кода . 8 142 Использование стороннего кода 143 Использование стороннего кода Между поставщиком и пользователем интерфейса существует естественная напряженность . Поставщики сторонних пакетов и инфраструктур стремятся к универсальности, чтобы их продукты работали в разных средах и были обра- щены к широкой аудитории . С другой стороны, пользователи желают получить интерфейс, специализирующийся на их конкретных потребностях . Эта напря- женность приводит к появлению проблем на границах наших систем . Для примера возьмем класс java.util.Map . Как видно из рис . 8 .1, Map имеет очень широкий интерфейс с многочисленными возможностями . Конечно, мощь и гиб- кость контейнера полезны, но они также создают некоторые неудобства . Допу- стим, наше приложение строит объект Map и передает его другим сторонам . При этом мы не хотим, чтобы получатели Map удаляли данные из полученного кон- тейнера . Но в самом начале списка стоит метод clear() , и любой пользователь Map может стереть текущее содержимое контейнера . А может быть, наша архитекту- ра подразумевает, что в контейнере должны храниться объекты только опреде- ленного типа, но Map не обладает надежными средствами ограничения типов со- храняемых объектов . Любой настойчивый пользователь сможет разместить в Map элементы любого типа . y clear() void – Map y containsKey(Object key) boolean – Map y containsValue(Object value) boolean – Map y entrySet() Set – Map y equals(Object o) boolean – Map y get(Object key) Object – Map y getClass() Class extends Object> – Object y hashCode() int – Map y isEmpty() boolean – Map y keySet() Set – Map y notify() void – Object y notifyAll() void – Object y put(Object key, Object value) Object – Map y putAll(Map t) void – Map y remove(Object key) Object – Map y size() int – Map y toString() String – Object y values() Collection – Map y wait() void – Object y wait(long timeout) void – Object y wait(long timeout, int nanos) void – Object Рис . 8 .1 . Методы Map Если в приложении требуется контейнер Map с элементами Sensor , его можно со- здать следующим образом: Map sensors = new HashMap(); 143 144 Глава 8 . Границы Когда другой части кода понадобится обратиться к элементу, мы видим код сле- дующего вида: Sensor s = (Sensor)sensors.get(sensorId ); Причем видим его не только в этом месте, но снова и снова по всему коду . Клиент кода несет ответственность за получение Object из Map и его приведение к правиль- ному типу . Такое решение работает, но «чистым» его не назовешь . Кроме того, этот код не излагает свою историю, как ему положено . Удобочитаемость кода можно было бы заметно улучшить при помощи шаблонов (параметризованных контейнеров): Map Sensor s = sensors.get(sensorId ); Но и такая реализация не решает проблемы: Map предоставляет намного больше возможностей, чем нам хотелось бы . Свободная передача Map по системе означает, что в случае изменения интерфейса Map исправления придется вносить во множестве мест . Казалось бы, такие изменения маловероятны, но вспомните, что интерфейс изменился при добавлении поддержки шаблонов в Java 5 . В самом деле, мы видели системы, разработчики которых воздерживались от использования шаблонов из-за боль- шого количества потенциальных изменений, связанных с частым использова- нием Map Ниже представлен другой, более чистый вариант использования Map . С точки зре- ния пользователя Sensors совершенно не важно, используются шаблоны или нет . Это решение стало (и всегда должно быть) подробностью реализации . public class Sensors { private Map sensors = new HashMap(); public Sensor getById(String id) { return (Sensor) sensors.get(id); } //... } Граничный интерфейс ( Map ) скрыт от пользователя . Он может развиваться не- зависимо, практически не оказывая никакого влияния на остальные части при- ложения . Применение шаблонов уже не создает проблем, потому что все преоб- разования типов выполняются в классе Sensors Этот интерфейс также приспособлен и ограничен в соответствии с потребностя- ми приложения . Код становится более понятным, а возможности злоупотребле- ний со стороны пользователя сокращаются . Класс Sensors может обеспечивать выполнение архитектурных требований и требований бизнес-логики . Поймите правильно: мы не предлагаем инкапсулировать каждое применение Map в этой форме . Скорее, мы рекомендуем ограничить передачу Map (или любого 144 Исследование и анализ границ 145 другого граничного интерфейса) по системе . Если вы используете граничный интерфейс вроде Map , держите его внутри класса (или тесно связанного семейства классов), в которых он используется . Избегайте его возвращения или передачи в аргументах при вызовах методов общедоступных API . Исследование и анализ границ Сторонний код помогает нам реализовать больше функциональности за меньшее время . С чего начинать, если мы хотим использовать сторонний пакет? Тести- рование чужого кода не входит в наши обязанности, но, возможно, написание тестов для стороннего кода, используемого в наших продуктах, в наших же интересах . Допустим, вам не ясно, как использовать стороннюю библиотеку . Можно потра- тить день-два (или более) на чтение документации и принятие решений о том, как работать с библиотекой . Затем вы пишете код, использующий стороннюю библиотеку, и смотрите, делает ли он то, что ожидалось . Далее вы, скорее всего, погрязнете в долгих сеансах отладки, пытаясь разобраться, в чьем коде возникают ошибки – в стороннем или в вашем собственном . Изучение чужого кода – непростая задача . Интеграция чужого кода тоже сложна . Одновременное решение обоих задач создает двойные сложности . А что, если пойти по другому пути? Вместо того чтобы экспериментировать и опробовать новую библиотеку в коде продукта, можно написать тесты, проверяющие наше понимание стороннего кода . Джим Ньюкирк ( Jim Newkirk) называет такие тесты «учебными тестами» [BeckTDD, pp . 136–137] . В учебных тестах мы вызываем методы стороннего API в том виде, в котором намереваемся использовать их в своем приложении . Фактически выполняется контролируемый эксперимент, проверяющий наше понимание стороннего API . Основное внимание в тестах направлено на то, чего мы хотим добиться при по- мощи API . Изучение log4j Допустим, вместо того чтобы писать специализированный журнальный модуль, мы хотим использовать пакет apache log4j . Мы загружаем пакет и открываем страницу вводной документации . Не особенно вчитываясь в нее, мы пишем свой первый тестовый сценарий, который, как предполагается, будет выводить на консоль строку «hello» . @Test public void testLogCreate() { Logger logger = Logger.getLogger("MyLogger"); logger.info("hello"); } 145 146 Глава 8 . Границы При запуске журнальный модуль выдает ошибку . В описании ошибки говорится, что нам понадобится нечто под названием Appender . После непродолжительных поисков в документации обнаруживается класс ConsoleAppender . Соответственно, мы создаем объект ConsoleAppender и проверяем, удалось ли нам раскрыть секреты вывода журнала на консоль: @Test public void testLogAddAppender() { Logger logger = Logger.getLogger("MyLogger"); ConsoleAppender appender = new ConsoleAppender(); logger.addAppender(appender); logger.info("hello"); } На этот раз выясняется, что у объекта Appender нет выходного потока . Странно – логика подсказывает, что он должен быть . После небольшой помощи от Google опробуется следующее решение: @Test public void testLogAddAppender() { Logger logger = Logger.getLogger("MyLogger"); logger.removeAllAppenders(); logger.addAppender(new ConsoleAppender( new PatternLayout("%p %t %m%n"), ConsoleAppender.SYSTEM_OUT)); logger.info("hello"); } Заработало; на консоли выводится сообщение со словом «hello»! На первый взгляд происходящее выглядит немного странно: мы должны указывать Console- Appender , что данные выводятся на консоль . Еще интереснее, что при удалении аргумента ConsoleAppender.SystemOut сообщение «hello» все равно выводится . Но если убрать аргумент PatternLayout , снова начи- наются жалобы на отсутствие выходного потока . Все это выглядит очень странно . После более внимательного чтения документации мы видим, что конструктор ConsoleAppender по умолчанию «не имеет конфигурации» – весьма неочевидное и бесполезное решение . Похоже, это ошибка (или по крайней мере нелогичность) в log4j После некоторых поисков, чтения документации и тестирования мы приходим к листингу 8 .1 . Попутно мы получили много полезной информации о том, как работает log4j , и закодировали ее в наборе простых модульных тестов . листинг 8 .1 . LogTest.java public class LogTest { private Logger logger; @Before public void initialize() { logger = Logger.getLogger("logger"); logger.removeAllAppenders(); 146 Исследование и анализ границ 147 Logger.getRootLogger().removeAllAppenders(); } @Test public void basicLogger() { BasicConfigurator.configure(); logger.info("basicLogger"); } @Test public void addAppenderWithStream() { logger.addAppender(new ConsoleAppender( new PatternLayout("%p %t %m%n"), ConsoleAppender.SYSTEM_OUT)); logger.info(“addAppenderWithStream”); } @Test public void addAppenderWithoutStream() { logger.addAppender(new ConsoleAppender( new PatternLayout("%p %t %m%n"))); logger.info("addAppenderWithoutStream"); } } Теперь мы знаем, как инициализировать простейший консольный вывод и можем воплотить эти знания в специализированном журнальном классе, чтобы изоли- ровать остальной код приложения от граничного интерфейса log4j учебные тесты: выгоднее, чем бесплатно Учебные тесты не стоят ничего . API все равно приходится изучать, а написание тестов является простым способом получения необходимой информации, в изо- ляции от рабочего кода . Учебные тесты были точно поставленными эксперимен- тами, которые помогли нам расширить границы своего понимания . Учебные тесты не просто бесплатны – они приносят дополнительную прибыль . При выходе новых версий сторонних пакетов вы сможете провести учебные тесты и выяснить, не изменилось ли поведение пакета . Учебные тесты позволяют убедиться в том, что сторонние пакеты, используе- мые в коде, работают именно так, как мы ожидаем . Нет никаких гарантий, что сторонний код, интегрированный в наши приложения, всегда будет сохранять совместимость . Например, авторы могут изменить код в соответствии с какими- то новыми потребностями . Изменения также могут происходить из-за исправ- ления ошибок и добавления новых возможностей . Выход каждой новой версии сопряжен с новым риском . Если в стороннем пакете появятся изменения, несо- вместимые с нашими тестами, мы сразу узнаем об этом . Впрочем, независимо от того, нужна ли вам учебная информация, получаемая в ходе тестирования, в системе должна существовать четкая граница, которая под- 147 148 Глава 8 . Границы держивается группой исходящих тестов, использующих интерфейс по аналогии с кодом продукта . Без граничных тестов, упрощающих процесс миграции, у нас появляются причины задержаться на старой версии дольше необходимого . Использование несуществующего кода Также существует еще одна разновидность границ, отделяющая известное от не- известного . В коде часто встречаются места, в которых мы не располагаем полной информацией . Иногда то, что находится на другой стороне границы, остается не- известным (по крайней мере в данный момент) . Иногда мы намеренно не желаем заглядывать дальше границы . Несколько лет назад я работал в группе, занимавшейся разработкой программ- ного обеспечения для системы радиосвязи . В нашем продукте была подсистема «Передатчик», о которой мы почти ничего не знали, а люди, ответственные за разработку этой подсистемы, еще не дошли до определения своего интерфейса . Мы не хотели простаивать и поэтому начали работать подальше от неизвестной части кода . Мы неплохо представляли себе, где заканчивалась наша зона ответственности и начиналась чужая территория . В ходе работы мы иногда наталкивались на границу . Хотя туманы и облака незнания скрывали пейзаж за границей, в ходе работы мы начали понимать, каким должен быть граничный интерфейс . Пере- датчику должны были отдаваться распоряжения следующего вида: Настроить передатчик на заданную частоту и отправить аналоговое представ- ление данных, поступающих из следующего потока. Мы тогда понятия не имели, как это будет делаться, потому что API еще не был спроектирован . Поэтому подробности было решено отложить на будущее . Чтобы не останавливать работу, мы определили собственный интерфейс с бро- ским именем Transmitter . Интерфейс содержал метод transmit , которому при вызове передавались частота и поток данных . Это был тот интерфейс, который нам хотелось бы иметь . У этого интерфейса было одно важное достоинство: он находился под нашим контролем . В результате клиентский код лучше читался, а мы в своей работе могли сосредоточиться на том, чего стремились добиться . На рис . 8 .2 мы видим, что классы CommunicationsController отделены от API пере- датчика (который находился вне нашего контроля и оставался неопределенным) . Использование конкретного интерфейса нашего приложения позволило сохра- нить чистоту и выразительность кода CommunicationsController . После того как другая группа определила API передатчика, мы написали класс TransmitterAdapter для «наведения мостов» . АДАПТЕР 1 инкапсулировал взаимодействие с API и создавал единое место для внесения изменений в случае развития API . Такая архитектура также создает в коде очень удобный «стык � » для тестирования . Используя подходящий FakeTransmitter , мы можем тестировать классы Communi- 1 См . описание паттерна АДАПТЕР в [GOF] . 148 |