Хотя концепции «внедрения зависимостей» и «слабой связи» пропагандируются и пишутся как минимум в течение последнего десятилетия, я все еще постоянно нахожу примеры, которые заставляют меня в целом думать, что мы, разработчики, возможно, недооцениваем сила этих простых идей и связанных с ними практик. В этой статье я опишу реальный простой сценарий, с которым я недавно столкнулся, и укажу, как несколько очень незначительных изменений в мышлении могли бы значительно улучшить дизайн и избежать многих проблем в будущем. Я предоставлю некоторый пример кода, который иллюстрирует, почему игнорирование этих принципов может привести к неприятностям, а также пример кода, который иллюстрирует, как мы можем очистить наш дизайн и сделать нашу жизнь намного проще.
Недавно я столкнулся с этой проблемой в проекте… Я обнаружил необходимость обновить класс Java, представляющий запланированное «задание», которое извлекало очень большой файл (содержащий сотни миллионов строк / записей) из удаленного URL-адреса и обрабатывало этот файл. , Вкратце, вот основные обязанности для этого класса:
- Загрузите файл с URL в локальную файловую систему.
- Проанализируйте каждую строку, содержащуюся в загруженном файле, и проверьте, представляет ли строка запись, в которой заинтересовано приложение.
- Для каждой интересующей строки обновите базу данных приложения — в зависимости от содержимого строки эта строка либо будет проигнорирована, либо помечает класс для удаления существующих записей или добавления новых записей в несколько таблиц в базе данных приложения.
Именно эти две последние функции заставили нас обновить алгоритм класса, потому что при обработке файлов, содержащих такое огромное количество записей, которые нужно проанализировать и обработать, задание обычно выполнялось не менее пары дней. Это фон, но реальная цель этого поста — функция номер один выше — загрузка файла с удаленного URL. Со всеми изменениями, которые я сделал для оптимизации алгоритма, мне нужно было проверить поведение класса — не было никакого модульного теста, и я хотел написать его. Идея модульного тестирования состоит в том, чтобы изолировать модуль (почти всегда один класс — «тестируемый класс»). Однако я столкнулся с некоторыми проблемами:
- Тестируемый класс вызывал закрытый метод для загрузки файла. Почему это проблема? С помощью этой реализации я не могу контролировать, что внешний веб-сайт вернет мне в любой момент времени, так как я могу написать тест, который проверяет, правильно ли мой класс обрабатывает возвращенный файл? Кроме того, очень плохо, когда юнит-тесты взаимодействуют с любой внешней системой, особенно если это производственная система. И, если эта система не работает или сломана, мои модульные тесты будут ломаться, хотя мой код может быть просто в порядке.
- Тестируемый класс непосредственно создает экземпляры объектов доступа к данным (DAO), которые он использует для обновления базы данных приложения. Почему это проблема? Хотя я действительно забочусь о том, что данные, проанализированные из загруженного файла, корректно обновляются в базе данных приложения, это не является целью модульного теста — я могу позже написать более всеобъемлющий интеграционный тест, чтобы проверить более полный «поток», но это Я не хочу действительно обновлять свою базу данных — только проверяю, что мой тестируемый класс вызывает DAO, когда и с параметрами, которые я ожидаю. Путем создания экземпляров DAO напрямую, тестируемый класс вмешивался в моя способность контролировать это.
Приведенный ниже фрагмент кода является упрощением, предназначенным для демонстрации оригинального проблемного подхода:
public class BadSyncDBWithExternalSiteJob { private static final String REMOTE_SITE_URL = "http://mysite.com/update-file.txt"; private static final String DOWNLOADED_FILE_LOCATION = "./downloadedFileToParse"; private SiteSyncDAOImpl siteSyncDAO = new SiteSyncDAOImpl(); public void execute() throws Exception { File downloadedFileToParse = retrieveRemoteFile(); // Parse file, update local database via the siteSyncDAO } private File retrieveRemoteFile() throws Exception { File downloadedFile = null; BufferedInputStream in = null; FileOutputStream fout = null; // Yes, there are better ways to do this - e.g. Apache's FileUtils try { in = new BufferedInputStream(new URL(REMOTE_SITE_URL).openStream()); fout = new FileOutputStream(DOWNLOADED_FILE_LOCATION); byte data[] = new byte[1024]; int count; while ((count = in.read(data, 0, 1024)) != -1) { fout.write(data, 0, count); } downloadedFile = new File(DOWNLOADED_FILE_LOCATION); } finally { if (in != null) in.close(); if (fout != null) fout.close(); } return downloadedFile; } }
Как вы можете видеть, класс, который анализирует файл и обновляет базу данных, также решает, какой URL-адрес извлечь файл с удаленного сайта, и фактически загружает файл. Он настолько тесно связан с этими функциями, насколько это возможно. Таким образом, чтобы протестировать этот класс, мои тесты всегда будут взаимодействовать и зависеть от внешнего URL-адреса, что означает, что он зависит как от текущего состояния этого сайта, так и от содержимого найденного файла в любой момент времени. Это совершенно недопустимо. Вы также можете видеть, что он создает свой собственный DAO. Это мешает возможности модульного тестирования этого класса, поскольку он всегда будет использовать этот DAO и обновлять базу данных — что мне не нужно.
Итак, что мог сделать разработчик, чтобы избежать этих проблем? Ниже приведены некоторые измененные фрагменты кода, в которых показана реализация вышеуказанного класса. Проблемы устранены путем отделения его от деталей реализации, о которых он не должен заботиться, и разрешения внедрения зависимостей:
public class GoodSyncDBWithExternalSiteJob { private ExternalFileDownloader externalSiteFileDownloader = null; private SiteSyncDAO siteSyncDAO = null; public void setSiteSyncDAO(SiteSyncDAO siteSyncDAO) { this.siteSyncDAO = siteSyncDAO; } public void setExternalSiteFileDownloader(ExternalFileDownloader externalSiteFileDownloader) { this.externalSiteFileDownloader = externalSiteFileDownloader; } public void execute() throws Exception { File downloadedFileToParse = externalSiteFileDownloader.retrieveRemoteFile(); // Parse file, update local database via the siteSyncDAO } }
public interface ExternalFileDownloader { public void setRemoteSiteURL(String remoteSiteURL); public void setDownloadedFileLocation(String downloadedFileLocation); public File retrieveRemoteFile() throws Exception; }
public class ExternalURLFileDownloader implements ExternalFileDownloader { private String remoteSiteURL = null; private String downloadedFileLocation = null; public void setRemoteSiteURL(String remoteSiteURL) { this.remoteSiteURL = remoteSiteURL; } public void setDownloadedFileLocation(String downloadedFileLocation) { this.downloadedFileLocation = downloadedFileLocation; } public File retrieveRemoteFile() throws Exception { File downloadedFile = null; BufferedInputStream in = null; FileOutputStream fout = null; // Yes, there are better ways to do this - e.g. Apache's FileUtils try { in = new BufferedInputStream(new URL(remoteSiteURL).openStream()); fout = new FileOutputStream(downloadedFileLocation); byte data[] = new byte[1024]; int count; while ((count = in.read(data, 0, 1024)) != -1) { fout.write(data, 0, count); } downloadedFile = new File(downloadedFileLocation); } finally { if (in != null) in.close(); if (fout != null) fout.close(); } return downloadedFile; } }
Обратите внимание на следующее о классе GoodSyncDBWithExternalSiteJob:
- Теперь требуется внедрение экземпляра интерфейса ExternalFileDownloader для обработки всех деталей загрузки файла, включая информацию о том, где и как. Фактически, GoodSyncDBWithExternalSiteJob больше не знает ничего о файле, даже о том, был ли он загружен из удаленного местоположения. Наши модульные тесты могут внедрить «фиктивную» версию ExternalFileDownloader, которая возвращает «консервированный» файл, содержащий одни и те же записи, каждый раз, когда мы выполняем наши модульные тесты.
- Также требуется инъекция DAO. Я не буду фокусироваться на DAO — те же концепции, которые обсуждались для «загрузчика файлов», применимы и к DAO. Класс задания не должен решать, какую реализацию DAO он должен использовать, и создавать его экземпляр — мы должны иметь возможность «связать» реализацию DAO, которую мы хотим использовать во время выполнения.
ПРИМЕЧАНИЕ. Для краткости примера кода я проигнорировал хорошие методы обработки исключений. Мы все знаем, что не рекомендуется, чтобы наши методы генерировали общие экземпляры «исключений». Мы делаем, не так ли? Пожалуйста скажи да.»
Помимо обеспечения возможности тестирования, эти простые изменения предоставляют и другие мощные преимущества:
- Слабое связывание: класс, который анализирует и решает, что необходимо обновить в базе данных, теперь полностью изолирован от деталей, из которых извлекается файл и как он извлекается. Теперь у меня есть контроль над «сотрудниками» класса. Я могу внедрить фиктивные экземпляры — мои собственные фиктивные объекты или объекты, созданные с помощью «фиктивной» инфраструктуры, такой как EasyMock, — которые ведут себя в соответствии с моими потребностями в тестировании и возвращают объекты в мой тестируемый класс предсказуемым образом каждый раз, когда я запускаю свои тесты.
- Сплоченность: классы теперь имеют четко определенные, узкие обязанности. Поскольку приложения становятся больше, значение этого атрибута не может быть завышено в целях удобства обслуживания и понятности.
- Более чистый и простой дизайн: в связи с вышеизложенным, это просто дизайн, который легче понять, изменить и поддерживать.
Это пример того, где следование философии дизайна, основанного на тестировании (TDD), может окупиться Если бы разработчик сначала писал свои модульные тесты или даже в тандеме с реализацией производственного кода, такого радикального рефакторинга можно было бы избежать, и этот класс был бы модульным с самого начала. Вы можете утверждать, что разработчик всегда может провести рефакторинг позже для достижения этой цели. Однако, проблемы с этой точки зрения, по моему опыту:
- Разработчикам редко предоставляется время, необходимое для реализации рефакторинга существующего кода, а тем более для написания модульных тестов для кода, который воспринимается как «работающий нормально сейчас».
- Как правило, гораздо сложнее выяснить, как выполнить рефакторинг и модульное тестирование существующего кода, чем сделать несколько простых шагов, чтобы очистить проект и написать тесты с самого начала.
- Во многих случаях тестируемый класс будет использоваться несколькими компонентами приложения, что может означать, что разработчик теперь должен рассмотреть все способы использования класса. Это может привести к тому, что разработчик попадет в сценарий «это изменение работает для этого варианта использования и нарушает другой сценарий использования». Я лично сталкивался с случаями, когда попытка рефакторинга существующего класса, который казался очень простым, требовала нескольких дней усилий.
Я расскажу о некоторых из этих тем — слабая связь, TDD, фиктивные фреймворки и т. Д. — в будущих публикациях в блоге. На данный момент нам достаточно начать, просто не забывая спроектировать наши классы таким образом, чтобы их обязанности были довольно узкими и чтобы их соавторы / зависимости могли быть внедрены во время выполнения и определены через интерфейсы вместо конкретных реализаций. Как только мы сделаем это в рамках нашей стандартной практики, наши проекты будут более чистыми и гибкими, а наше модульное тестирование будет проще и эффективнее.