Создание, анализ ирефакторинг
Скачать 3.16 Mb.
|
354 Глава 17 . Запахи и эвристические правила n7: Имена должны описывать побочные эффекты Имена должны описывать все, что делает функция, переменная или класс . Не скрывайте побочные эффекты за именами . Не используйте простые глаголы для описания функции, которая делает что-то помимо этой простой операции . Для примера возьмем следующий код из TestNG: public ObjectOutputStream getOos() throws IOException { if (m_oos == null) { m_oos = new ObjectOutputStream(m_socket.getOutputStream()); } return m_oos; } Функция не ограничивается простым получением m_oos ; она создает объект m_oos , если он не был создан ранее . Таким образом, эту функцию было бы правильнее назвать createOrReturnOos тесты T1: нехватка тестов Сколько тестов должен включать тестовый пакет? К сожалению, многие про- граммисты руководствуются принципом «Пожалуй, этого хватит» . Тестовый пакет должен тестировать все, что может сломаться . Если в системе остались условия, не проверенные тестами, или вычисления, правильность которых не подтверждена, значит, количество тестов недостаточно . T2: Используйте средства анализа покрытия кода Средства анализа покрытия сообщают о пробелах в вашей стратегии тестирова- ния . Они упрощают поиск модулей, классов и функций с недостаточно полным тестированием . Во многих IDE используются визуальные обозначения: строки, покрытые тестами, выделяются зеленым цветом, а непокрытые — красным . Это позволяет легко и быстро обнаружить команды if или catch , тело которых не проверяется тестами . T3: не пропускайте тривиальные тесты Тривиальные тесты пишутся легко, а их информативная ценность превышает затраты времени на их создание . 354 Тесты 355 T4: Отключенный тест как вопрос Иногда мы не уверены в подробностях поведения системы, потому что неясны сами требования к программе . Вопрос о требованиях можно выразить в виде теста — закомментированного или помеченного аннотацией @Ignore . Выбор за- висит от того, компилируется или нет код, к которому относится неопределен- ность . T5: тестируйте граничные условия Особенно тщательно тестируйте граничные условия . Программисты часто правильно реализуют основную часть алгоритма, забывая о граничных ситуа- циях . T6: тщательно тестируйте код рядом с ошибками Ошибки часто собираются группами . Если вы обнаружили ошибку в функции, особенно тщательно протестируйте эту функцию . Может оказаться, что ошибка была не одна . T7: Закономерности сбоев часто несут полезную информацию Иногда анализ закономерностей в сбоях тестовых сценариев помогает выявить причины возникших проблем . Это еще один аргумент в пользу максимальной полноты тестовых сценариев . Всесторонние наборы тестовых сценариев, упо- рядоченные логичным образом, выявляют закономерности . Простой пример: вы заметили, что все тесты с входными данными, длина которых превышает пять символов, завершаются неудачей? Или что любой тест, который передает во втором аргументе функции отрицательное число, не проходит? Ино- гда простая закономерность чередования красного и зеленого в тестовом отчете заставляет нас воскликнуть «Ага!» на пути к правильному решению . Интересный пример такого рода приведен на с . 303 . T8: Закономерности покрытия кода часто несут полезную информацию Анализ того, какой код выполняется или не выполняется в ходе тестирования, иногда подсказывает причины возможных сбоев в ходе тестирования . 355 356 Глава 17 . Запахи и эвристические правила T9: тесты должны работать быстро Медленный тест не выполняется за разумное время . Если время поджимает, из тестового пакета первыми будут удалены медленные тесты . Сделайте все необ- ходимое для того, чтобы ваши тесты работали быстро . Заключение Приведенный список эвристических правил и «запахов» не претендует на пол- ноту . Я вообще не уверен в том, можно ли составить полный список такого рода . Но вероятно, стопроцентная полнота здесь и не нужна, поскольку список всего лишь дает косвенное представление о системе ценностей . Именно эта система ценностей является нашей целью — и основной темой кни- ги . Невозможно написать чистый код, действуя по списку правил . Нельзя стать мастером, изучив набор эвристик . Профессионализм и мастерство формируются на основе ценностей, которыми вы руководствуетесь в обучении . литература [Refactoring]: Refactoring: Improving the Design of Existing Code, Martin Fowler et al ., Addison-Wesley, 1999 . [PRAG]: The Pragmatic Programmer, Andrew Hunt, Dave Thomas, Addison-Wesley, 2000 . [GOF]: Design Patterns: Elements of Reusable Object Oriented Software, Gamma et al ., Addison-Wesley, 1996 . [Beck97]: Smalltalk Best Practice Patterns, Kent Beck, Prentice Hall, 1997 . [Beck07]: Implementation Patterns, Kent Beck, Addison-Wesley, 2008 . [PPP]: Agile Software Development: Principles, Patterns, and Practices, Robert C . Martin, Prentice Hall, 2002 . [DDD]: Domain Driven Design, Eric Evans, Addison-Wesley, 2003 . 356 Многопоточность II Бретт Л. Шухерт Данное приложение дополняет и углубляет материал главы «Многопоточность», с . 207 . Оно написано в виде набора независимых разделов, которые можно читать в произвольном порядке . Чтобы такое чтение было возможно, материал разделов частично перекрывается . Пример приложения «клиент/сервер» Представьте простое приложение «клиент/сервер» . Сервер работает в режиме ожидания, прослушивая сокет на предмет клиентских подключений . Клиент подключается и отправляет запросы . Сервер Ниже приведена упрощенная версия серверного приложения . Полный исходный код примера приводится, начиная со с . 385 . ServerSocket serverSocket = new ServerSocket(8009); while (keepProcessing) { try { Socket socket = serverSocket.accept(); process(socket); } catch (Exception e) { handle(e); } } Приложение ожидает подключения, обрабатывает входящее сообщение, а затем снова ожидает следующего клиентского запроса . Код клиента для подключения к серверу выглядит так: private void connectSendReceive(int i) { try { Socket socket = new Socket("localhost", PORT); MessageUtils.sendMessage(socket, Integer.toString(i)); MessageUtils.getMessage(socket); socket.close(); А 357 358 Приложение А . Многопоточность II } catch (Exception e) { e.printStackTrace(); } } Как работает пара «клиент/сервер»? Как формально описать ее производитель- ность? Следующий тест проверяет, что производительность является «прием- лемой»: @Test(timeout = 10000) public void shouldRunInUnder10Seconds() throws Exception { Thread[] threads = createThreads(); startAllThreadsw(threads); waitForAllThreadsToFinish(threads); } Чтобы по возможности упростить пример, я исключил из него подготовительный код (см . «ClientTest .java», с . 387) . Тест предполагает, что обработка должна быть завершена за 10 000 миллисекунд . Перед нами классический пример оценки производительности системы . Систе- ма должна завершить обработку серии клиентских запросов за 10 секунд . Если сервер сможет обработать все клиентские запросы за положенное время, то тест пройдет . А что делать, если тест завершится неудачей? В однопоточной модели практиче- ски невозможно как-то ускорить обработку запросов (если не считать реализации цикла опроса событий) . Сможет ли многопоточная модель решить проблему? Может, но нам необходимо знать, в какой области расходуется основное время выполнения . Возможны два варианта: Ввод/вывод – использование сокета, подключение к базе данных, ожидание подгрузки из виртуальной памяти и т . д . Процессор – числовые вычисления, обработка регулярных выражений, уборка мусора и т . д . В системах время обычно расходуется в обеих областях, но для конкретной операции одна из областей является доминирующей . Если код в основном ориентирован на обработку процессором, то повышение вычислительной мощ- ности способно улучшить производительность и обеспечить прохождение теста . Однако количество процессорных тактов все же ограничено, так что реализация многопоточной модели в процессорно-ориентированных задачах не ускорит их выполнения . С другой стороны, если значительное время в выполняемом процессе расходу- ется на операции ввода/вывода, то многопоточная модель способна повысить эффективность работы . Пока одна часть системы ожидает ввода/вывода, другая часть использует время ожидания для выполнения других действий, обеспечивая более эффективное использование процессорного времени . 358 Пример приложения «клиент/сервер» 359 Реализация многопоточности Допустим, тест производительности не прошел . Как повысить производитель- ность и обеспечить прохождение теста? Если серверный метод process ориен- тирован на ввод/вывод, одна из возможных реализаций многопоточной модели выглядит так: void process(final Socket socket) { if (socket == null) return; Runnable clientHandler = new Runnable() { public void run() { try { String message = MessageUtils.getMessage(socket); MessageUtils.sendMessage(socket, "Processed: " + message); closeIgnoringException(socket); } catch (Exception e) { e.printStackTrace(); } } }; Thread clientConnection = new Thread(clientHandler); clientConnection.start(); } Допустим, в результате внесенных изменений тест проходит 1 ; задача решена, верно? Анализ серверного кода На обновленном сервере тест успешно завершается всего за одну с небольшим секунду . К сожалению, приведенное решение наивно, и оно создает ряд новых проблем . Сколько потоков может создать наш сервер? Код не устанавливает ограничений, поэтому сервер вполне может столкнуться с ограничениями, установленными виртуальной машиной Java ( JVM) . Для многих простых систем этого достаточно . А если система обслуживает огромное количество пользователей в общедоступ- ной сети? При одновременном подключении слишком многих пользователей система «заглохнет» . Но давайте ненадолго отложим проблемы с поведением . Чистота и структура представленного кода тоже оставляют желать лучшего . Какие ответственности возложены на серверный код? Управление подключением к сокетам . Обработка клиентских запросов . 1 Вы можете убедиться в этом сами, тестируя код до и после внесения изменений . Однопо- точный код приведен на с . 385, а многопоточный – на с . 389 . 359 360 Приложение А . Многопоточность II Политика многопоточности . Политика завершения работы сервера . К сожалению, все эти ответственности реализуются кодом функции process Кроме того, код распространяется на несколько разных уровней абстракции . Следовательно, какой бы компактной ни была функция process , ее все равно не- обходимо разбить на несколько меньших функций . У серверного кода несколько причин для изменения; следовательно, он нарушает принцип единой ответственности . Чтобы код многопоточной системы оставался чистым, управление потоками должно быть сосредоточено в нескольких хорошо контролируемых местах . Более того, код управления потоками не должен делать ничего другого . Почему? Да хотя бы потому, что отслеживать проблемы многопо- точности достаточно сложно и без параллельного отслеживания других проблем, не имеющих ничего общего с многопоточностью . Если создать отдельный класс для каждой из ответственностей, перечисленных выше (включая управление потоками), то последствия любых последующих изменений стратегии управления потоками затронут меньший объем кода и не будут загрязнять реализацию других обязанностей . Кроме того, такое разбиение упростит тестирование других модулей, так как вам не придется отвлекаться на многопоточные аспекты . Обновленная версия кода: public void run() { while (keepProcessing) { try { ClientConnection clientConnection = connectionManager.awaitClient(); ClientRequestProcessor requestProcessor = new ClientRequestProcessor(clientConnection); clientScheduler.schedule(requestProcessor); } catch (Exception e) { e.printStackTrace(); } } connectionManager.shutdown(); } Все аспекты, относящиеся к многопоточности, теперь собраны в объекте clientScheduler . Если в приложении возникнут многопоточные проблемы, ис- кать придется только в одном месте: public interface ClientScheduler { void schedule(ClientRequestProcessor requestProcessor); } Текущая политика реализуется легко: public class ThreadPerRequestScheduler implements ClientScheduler { public void schedule(final ClientRequestProcessor requestProcessor) { 360 Пример приложения «клиент/сервер» 361 Runnable runnable = new Runnable() { public void run() { requestProcessor.process(); } }; Thread thread = new Thread(runnable); thread.start(); } } Изоляция всего многопоточного кода существенно упрощает внесение любых изменений в политику управления потоками . Например, чтобы перейти на ин- фраструктуру Java 5 Executor, достаточно написать новый класс и подключить его к существующему коду (листинг А .1) . листинг А .1 . ExecutorClientScheduler.java import java.util.concurrent.Executor; import java.util.concurrent.Executors; public class ExecutorClientScheduler implements ClientScheduler { Executor executor; public ExecutorClientScheduler(int availableThreads) { executor = Executors.newFixedThreadPool(availableThreads); } public void schedule(final ClientRequestProcessor requestProcessor) { Runnable runnable = new Runnable() { public void run() { requestProcessor.process(); } }; executor.execute(runnable); } } Заключение Применение многопоточной модели в этом конкретном примере показывает, как повысить производительность системы, а также демонстрирует методологию проверки изменившейся производительности посредством тестовой инфра- структуры . Сосредоточение всего многопоточного кода в небольшом количестве классов – пример практического применения принципа единой ответственности . В случае многопоточного программирования это особенно важно из-за его не- тривиальности . 361 362 Приложение А . Многопоточность II Возможные пути выполнения Рассмотрим код incrementValue однострочного метода Java, не содержащего ци- клов или ветвления: public class IdGenerator { int lastIdUsed; public int incrementValue() { return ++lastIdUsed; } } Забудем о возможности целочисленного переполнения . Будем считать, что толь- ко один программный поток имеет доступ к единственному экземпляру IdGenera- tor . В этом случае существует единственный путь выполнения с единственным гарантированным результатом: Возвращаемое значение равно значению lastIdUsed , и оба значения на одну единицу больше значения lastIdUsed непосредственно перед вызовом метода . Что произойдет, если мы используем два программных потока, а метод оста- нется неизменным? Какие возможны результаты, если каждый поток вызовет incrementValue по одному разу? Сколько существует возможных путей вы- полнения? Начнем с результатов (допустим, lastIdUsed начинается со значе- ния 93): Поток 1 получает значение 94, поток 2 получает значение 95, значение last- IdUsed равно 95 . Поток 1 получает значение 95, поток 2 получает значение 94, значение lastIdUsed равно 95 . Поток 1 получает значение 94, поток 2 получает значение 94, значение last- IdUsed равно 94 . Последний результат выглядит удивительно, но он возможен . Чтобы понять, как образуются эти разные результаты, необходимо разобраться в количестве возможных путей выполнения и в том, как они исполняются виртуальной ма- шиной Java . Количество путей Чтобы вычислить количество возможных путей выполнения, начнем со сгенери- рованного байт-кода . Одна строка кода Java ( return ++lastIdUsed; ) преобразуется в восемь инструкций байт-кода . Выполнение этих восьми инструкций двумя по- токами может перемежаться подобно тому, как перемежаются карты в тасуемой колоде 1 . Хотя в каждой руке вы держите всего восемь карт, количество всевоз- можных перетасованных комбинаций чрезвычайно велико . 1 Это несколько упрощенное объяснение . Впрочем, для целей нашего обсуждения мы вос- пользуемся этой упрощенной моделью . 362 Пример приложения «клиент/сервер» 363 В простом случае последовательности из N команд, без циклов и условных пере- ходов, и T потоков, общее количество возможных путей выполнения будет равно ( )! ! T NT N ВыЧИСленИе ВОЗМОжных ВАРИАнтОВ уПОРяДОЧенИя Из сообщения электронной почты, отправленного Дядюшкой Бобом Бретту: При N шагов и T потоков общее количество в итоговой последовательности шагов равно T * N. Перед каждым шагом происходит переключение контекста, в ходе которого производится выбор между T потоками. Каждый путь может быть пред- ставлен в виде последовательности цифр, обозначающей переключение контек- стов. Так, для шагов A и B с потоками 1 и 2 возможны шесть путей: 1122, 1212, 1221, 2112, 2121 и 2211. Если использовать в записи обозначения шагов, мы получаем A1B1A2B2, A1A2B1B2, A1A2B2B1, A2A1B1B2, A2A1B2B1 и A2B2A1B1. Для трех потоков последовательность вариантов имеет вид 112233, 112323, 113223, 113232, 112233, 121233, 121323, 121332, 123132, 123123, . . . . Одно из свойств этих строк заключается в том, что каждый поток должен присут- ствовать в строке в N экземплярах. Таким образом, строка 111111 невозможна, потому что она содержит шесть экземпляров 1 и нуль экземпляров 2 и 3. Итак, нам нужно сгенерировать перестановки из N цифр 1, N цифр 2… и N цифр T. Искомое число равно числу перестановок из N * T объектов, то есть (N * T)!, но с удалением всех дубликатов. Таким образом, задача заключается в том, чтобы подсчитать дубликаты и вычесть их количество из (N * T )!. Сколько дубликатов содержит серия для двух шагов и двух потоков? Каждая строка из четырех цифр состоит из двух 1 и двух 2. Цифры каждой пары мож- но поменять местами без изменения смысла строки. Мы можем переставить две цифры 1 и/или две цифры 2. Таким образом, каждая строка существует в четырех изоморфных версиях; это означает, что у каждой строки имеются три дубликата. Три варианта из четырех повторяются, то есть только одна перестановка из че- тырех НЕ ЯВЛЯЕТСЯ дубликатом. 4! * 0.25 = 6. Похоже, наша схема рассуждений работает. Как вычислить количество дубликатов в общем случае? Для N = 2 и T = 2 можно переставить 1 и/или 2. Для N = 2 и T = 3 можно переставить 1, 2, 3, 1 и 2, 1 и 3 или 2 и 3. Количество вариантов равно количеству перестановок N. Допустим, существует P разных перестановок N. Количество разных вариантов размещения этих перестановок равно P**T. Таким образом, количество возможных изоморфных версий равно N!**T. Соот- ветственно, количество путей равно (T*N)!/(N!**T). Для исходного случая T = 2, N = 2 мы получаем 6 (24/4). Для N = 2 и T = 3 количество путей равно 720/8 = 90. Для N = 3 и T = 3 получается 9!/6^3 = 1680. В простейшем случае с одной строкой кода Java, эквивалентной восьми инструк- циям байт-кода, и двумя программными потоками общее количество возможных путей выполнения равно 12 870 . Если переменная lastIdUsed будет относиться 363 |