вторник, 19 июля 2011 г.

Разработка больших систем. Часть 5. Про хаки и рефакторинг.

Хаком назовём кусок кода, который не вкладывается в общую концепцию, не соответствует архитектуре проекта и решает ту или иную специфическую проблему.



Например, при создании программы, где есть пользователи и есть механизм их удаления не вовремя вспомнили, об опасности удаления аккаунта главного администратора системы. А поскольку сроки поджимали, то в программе появился код:

if(“admin”.equals(username)) {

showError(“Can't delete admin”);

return;

}


Как известно, в СССР секса не было.

Аналогично, в идеальной системе нет хаков.

Потому, что в идеальном мире:

· Есть неограниченное количество времени, для проведения любого количества рефакторингов

· Не существует сроков, при нарушении которых, пропадает всякий смысл в дальнейшей разработке

· Все внешние компоненты, используемые идеальной программой написаны идеальными разработчиками и не содержат ошибок. В крайнем случае, всегда есть время и бюджеты, чтобы исправить эти ошибки или вовсе написать все компоненты самостоятельно

За последние 15 лет, мне такие проекты, к сожалению, не попадались.

Поэтому, скажу крамольную вещь: в реальных проектах хаки имеют право быть.

При этом, аналогично «плохим» и «хорошим» багам, существуют «плохие» и «приемлемые» хаки. Для поддержания большой программной системы в стабильном состоянии, важно уметь отличать одни от других: стабильные системы не содержат «плохих» хаков.

Плохие и приемлемые хаки

Любой код, в своей работе опирается на те или иные предположения. Хороший код, опирается только на факты, которые невозможно изменить случайно или незаметно.

Хак (любой), по определению тем и отличается от хорошего кода, что он опирается на факты, которые могут перестать быть истинными в будущем. Причем эта «потеря истинности» может произойти в любое время и иногда может остаться незамеченной. Скажем, в приведенном выше примере, хак незаметно перестанет работать, если кто-то из разработчиков переименует аккаунт администратора.

Очевидно, для каждого хака мы можем взять весь набор фактов, на которые он опирается, и для каждого из них, определить условия, при которых хак перестанет работать.

Так вот приемлемым будет только такой хак, который ЯВНО СЛОМАЕТСЯ при выполнении любого из таких условий. «Явно сломается» - значит жестко упадет один из основных use-кейсов, и это не сможет пройти незамеченным для тестировщиков.

Приведенный выше пример является неприемлемым хаком, поскольку он обнаружится лишь при попытке удалить последнего администратора. Тот же самый хак можно сделать примлемым, если перед этой проверкой добавить еще одну:

if(!accountExists(“admin”)) {

throw new RuntimeException(“No admin exists”);

}


или лучше


assert(accountExists(“admin”));


Очевидно, хаки все равно не украшают код и не повышают стабильность системы. Поэтому, в любом случае их необходимо явно помечать (например тэгами //TODO или //FIXME) , и планировать их замену на качественный код в будущих релизах.

Энтропия и рефакторинг


Два факта:

1. Попытки немедленно уничтожать каждый приемлемый хак приводят к неприемлемому удорожанию проекта

2. Чрезмерное увеличение количества приемлемых хаков приводят к переусложнению кода и резкому снижению его понятности

Количество хаков в коде - это величина, которую необходимо постоянно отслеживать. Для каждого проекта опытным путём находится максимально допустимое количество хаков. При превышении этого количества необходимо делать рефакторинг.

Рефакторинг — это изменение архитектуры проекта с тем, чтобы она более точно соответствовала реально решаемым задачам. Обычно оказывается, что целый ряд хаков имеет одну и ту же причину. И эта причина состоит в особенностях архитектуры проекта. Как правило, можно выделить группу хаков, имеющих общую причину, проанализировать ее и предложить изменение в архитектуре проекта, позволяющее эту причину устранить. Собственно такое изменение — и есть рефакторинг. (В приведенном выше примере, рефакторингом может быть, например, введение ролей и замена проверки имени пользователя на наличие у него роли администратора. )

Замечание: Даже если все хаки имеют совершенно разные причины, необходимо делать рефакторинг. Однако такая ситуация, скорее всего, говорит об очень слабой архитектуре проекта. Возможно ее надо пересмотреть.


Читать полностью...

Разработка больших систем. Часть 4. Принципы ООП.

Принципы ООП очень хорошо описаны тут: http://www.oodesign.com/design-principles.html (правда по английски).

Их всего 5.

Здесь я хочу показать, что , по-сути, каждый из них служит описанной в третей части идее «программирования основанного на контрактах». Все они направлены на то, что контракты будут существовать, будут понятными и будут соблюдаться.

Принцип open-close

Класс должен быть закрыт для модификаций, но открыт для расширений.

Другими словами, если вам нужно новое поведение (новый контракт) наряду со старым поведением (существующий контракт), вы не должны вносить исправления в существующий код класса (чтобы не повредить выполнению старого контракта). Вместо этого, класс надо отнаследовать, и уже наследник должен соблюдать новый контракт.

Принцип инверсии зависимостей

Состоит из двух утверждений:
1. Высокоуровневые модули не должны зависеть от низкоуровневых модулей. И те и другие должны зависеть от абстракций.
2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций (конкретизируя их)

По сути, именно абстракции задают контракты. Отсутствие зависимостей между модулями — это перефразированное утверждение, приведенное в третей части «каждый класс - самостоятельная сущность, которая НИЧЕГО не знает о внутреннем строении других классов».
Второе утверждение говорит о первичности контрактов (абстракций) относительно деталей их реализаций. Иными словами, контракт не должно волновать, каким образом он будет выполнен.

Принцип сегрегации интерфейсов

Принцип запрещает создание интерфейсов, которые почти никто не сможет реализовать.
Интерфейс невозможно реализовать, если часть его методов относится к одной логической сущности, а часть — к другой. В этом случае, для реализации интерфейса нужно иметь обе сущности. На практике это, почти всегда не возможно.

Пример плохого интерфейса
interface HrenovaTucha {
void sentToPrinter(String text);
Image scan();
void burnCD(Image cd)
}


Что мне делать, если у меня нет сканера?

С точки зрения программирования, основанного на контрактах, данный принцип запрещает создание плохих контрактов: тех, которые в совокупности не определяют поведение одного разумного объекта.

Принцип единственной зоны ответственности

У каждого класса должна быть ровно одна причина для изменения.
Этот принцип запрещает создание классов неясного или смешанного назначения.
Если приглядеться к такому классу, всегда можно увидеть два или более разных, не связанных между собой, контракта , которые один класс пытается исполнять.
В результате мы получаем переусложненный класс, который невозможно поддерживать, и переусложненный контракт, который стоило бы разделить.

Принцип подменяемости Лискова

Классы наследники должны писаться так, чтобы их всегда можно было использовать там, где можно использовать их родителей.

Другими словами — класс наследник обязан соблюдать контракт класса-родителя. Однако наследник имеет право расширить контракт, введя новые методы. И полное право изменить способ выполнения контракта, перегрузив существующие методы.

Читать полностью...

понедельник, 18 июля 2011 г.

Разработка больших систем. Часть 3. О хорошем дизайне.

Как было упомянуто, одной из причин «плохих» багов является неверная архитектура приложения. И, хотя построение архитектуры является обязанностью архитектора, а не разработчика, разработчику полезно бывает уметь видеть стандартные ошибки в архитектуре и отличать правильную архитектуру от неправильной. Хотя бы для того, чтобы не испортить правильную архитектуру своими изменениями.

Здесь очень коротко изложу основные принципы дизайна.



Принцип отсутствия скрытых зависимостей

Если изменение в одном файле требует изменений в другом, а ты сделал первое и не сделал второе, то должна возникать ошибка на стадии компиляции.

Смысл: если этот принцип не соблюдается, то любое «неполное» изменение автоматически приводит к плохому багу.

Соблюсти этот принцип не так сложно, как может показаться. Достаточно мыслить каждый класс, как самостоятельную сущность, которая НИЧЕГО не знает о внутреннем строении других классов.

Во-первых, это означает, что множество публичных методов каждого класса надо мыслить как открытое API, которое может быть использовано как угодно, кем угодно, в любом порядке, из любого количества потоков и с любыми аргументами. И во всех этих случаях класс должен вести себя разумно. Каждый публичный метод должен выполнять ровно одно действие, которое можно описать одним предложением. Ожидаемое поведение методов из этого API называется контрактом класса.

Во-вторых, каждый класс должен также относиться к тем классам и интерфейсам, которые он использует: он не должен ничего предполагать об их внутреннем строении. Он пользуется их публичным API от которого ожидает разумного поведения (исполнения их контракта). Если поведение неразумно — это повод исправить «неразумный» класс, в соответствии с контрактом, а не подстроиться под это неразумное поведение.

Как только вы начинаете руководствоваться этими простыми соображениями, все зависимости между классами сводятся к использованию публичных методов, согласно контракту (т. е. заданному и ожидаемому поведению).

Изменение внутренностей классов становится локальным пока контракт продолжает соблюдаться. И только изменение публичных методов перестаёт быть локальным. Но оно немедленно приводит к ошибкам компиляции — все, кто использовал эти методы, более не собираются.

Кстати, при изменении контракта существующего метода, опытные программисты удаляют метод, работавший согласно старому контракту и добавляют новый, с другим именем. Делается это специально, чтобы «сломать» всех пользователей старого метода и убедиться, что ни один участок кода не ожидает от класса следования старому варианту контракта.

Упражнение

Назовите плохие методы в данном классе. Скажите, почему они плохие.
class AlertManager {
public List searchAlerts(String query);
public List searchAlerts2(String query);
public void processAlerts(List alerts);
public List doSearchAndThenDeleteAlertsIfBad(String query);
public Map getInternalAlertMap();
public Alert getAlertById(String id);
public void putAlert(String id,Alert alert);
}


Принцип одного контракта

В любой паре взаимодействующих классов один класс определяет весь контракт взаимодействия, а другой управляет этим взаимодействием, согласно этому контракту.
То есть если класс А вызывает публичное API класса Б, то класс Б не только не имеет права вызывать публичное API класса А, но и вообще не должен иметь прямую ссылка на класс А. Если классу А необходимо получать информацию о каких-то событиях от класса Б, то класс Б должен иметь в своем API методы для подписывания на данный вид информации, а класс А — использовать эти методы, согласно контракту класса Б.

Другими словами, из двух взаимодействующих классов ОДИН (в данном случае класс Б) определяет ВЕСЬ контракт взаимодействия. А другой управляет, оставаясь в рамках этого контракта.

Образный пример: Если вы (класс А) пришли в магазин (класс Б), вы ведете себя согласно правилам поведения в магазинах. Вы можете пользоваться интерфейсами магазина (касса, прилавки с товарами, жалобная книга). Если магазину нужно передать информацию для покупателей, она размещается на специальных стендах. И , если вам она нужна, вы подходите к стенду и читаете ее. При этом, сотрудники магазина не хватают вас за рукав и не пристают со своими требованиями. То есть, ситуацией управляете вы, но в рамках контракта, установленного магазином (скажем орать матом вы там не станете). Подобная организация магазина, делает его гораздо более предсказуемой и стабильной сущностью, чем рынок, где подобные правила не соблюдаются.

Нарушение этого принципа иногда приводит к зацикливанию (бесконечным цепочкам вызовов между разными классами) и всегда — к усложнению логики приложения до такой степени, что понять ее становится невозможно.

Принцип 80-20

Любая программная компонента, предназначенная для использования другими компонентами, должна решать 80% проблем. При попытке решить 100%, сложность компоненты превзойдет сложность решения проблем без ее использования.
Ключевым признаком нарушения этого принципа является появление методов, которые имеют среди своих параметров управляющие, влияющие на ход выполнения метода. Причем в случае, если ранее в этих методах таких параметров не было.

Например, был метод

doSearch(String query);

А вам хочется сделать из него метод

doSearch(String query,boolean useNewAdvacedApproach, double weightForDescriptionField,SpecialSearchParameters params);

Не делайте. Этим методом никто не сможет воспользоваться, ибо его контракт не очевиден. Вы и сами его не вспомните уже через месяц.

Принцип незавоевания

Ваша библиотека, API , компонент (и вообще что угодно) НЕ ДОЛЖНО конфликтовать с использованием стандартных способов разработки, существовавших до вас. Другими словами - дополняйте, но не подменяйте. Должно допускаться "смешанное" кодирование: с использованием вашего API и стандартного вместе.

Этот принцип проще показать на примере.

Классический пример - ненавидимый мною проект Apache Cayeene. В этой ORM системе авторы додумались до требования наследовать все объекты модели от их собственных классов. Очевидно, что если вы возьмете две библиотеки, написанные столь блестящим образом, и решающие разные задачи (например Cayeene и столь же глупую библиотеку, служащую, скажем для сериализации объектов), то вы не сможете совместить их в одном приложении. Или одна или другая завоюет его все.

Читать полностью...

пятница, 15 июля 2011 г.

Разработка больших систем. Часть 2. Работа с багами.

Все баги делятся на две категории – хорошие и плохие. Подход к их исправлению – совершенно разный. Поэтому важно отличать хороший баг от плохого. (Кстати, качество работы программиста определяется наличием в его коде плохих багов. Хорошие баги не являются признаком некачественного кода)



Хорошие баги

К хорошим багам относятся разного рода опечатки, сделанные по причине того, что люди не являются роботами. (Например, разного рода NPE и т д). Другой вид хорошего бага, локальная логическая ошибка в определенном месте кода, которая влияет только на это место. (Например, вместо “>” поставили “<” или немного неверно предсказали поведение внешнего компонента, и, поэтому, неверно интерпретировали его результат).

Общими признаками хороших багов является:

• Повторяемость (они всегда возникают при одних и тех же условиях)
• Локальность (они относятся только к одному объекту и никак не затрагивают другие объекты)

Опытный программист, в трезвом и незамученном состоянии делает только хорошие баги.

Плохие баги

Признаками плохого бага являются

• Трудноуловимость (он возникает иногда и никто не знает, как его повторить)
• Не локальность. (Если его нашли, способ его исправления не очевиден, поскольку он влияет на множество компонентов системы)

Мне известно три причины плохих багов:

• Неверный дизайн (архитектура) приложения
• Неправильно вносимые изменения (см. главу про изменения)
• Гены разработчика

(Последняя причина не является устранимой на современной стадии развития науки. )

Неверный дизайн приложения является проблемой архитектора, а не разработчика. Поэтому главной причиной плохого бага является неверный способ внесения изменений. Часто разработчики просто не задумываются над этим, и используют интуитивно понятный метод «запинывания»

Работа с разными типами багов

Первое, что необходимо сделать, это выяснить, является ли баг хорошим или плохим. Для этого надо понять его причину и проверить, является ли она следствием локальной ошибки. Т. е. может ли проблема быть устранена исправлением этой ошибки в одном месте и не приведет ли это к каким-либо побочным эффектам. Хорошим критерием является такой: если автор бага сходу скажет, что он тупо ошибся – то баг, скорее всего, хороший. Если же автор имел какие-то причины написать именно так, то перед вами плохой баг.

Правка хорошего бага

Сводится к исправлению тупой ошибки и проверке, что баг более не появляется.

Внимание: если после этого исправления баг не исчез, его следует рассматривать, как плохой!

Правка плохого бага

Проводится в точности также, как и внесение изменения! (см главу про внесение изменений).

Читать полностью...

четверг, 14 июля 2011 г.

Разработка больших систем. Часть 1. Внесение изменений.

Очень давно ничего сюда не писал.
А сейчас вот решил написать на другую тему...
Пожалуй, даже более близкую мне - про технологию разработки больших программных систем.

По роду своей работы, я постоянно поправляю молодых программистов, рассказываю им о допущенных ими ошибках, объясняю, как надо писать, чтобы такого не повторялось. В какой-то момент стал записывать эти пояснения. И вот сейчас решил опубликовать.
Кстати, буду благодарен за конструктивную критику. Особенно от тех, кто также профессионально занимается разработкой больших программных систем.

Начну с первой (исторически) части - как делать (и как НЕ делать) изменения в большой программной системе.



Внесение изменений

Как вносить изменения (или исправления) в сложную систему.

Неверный способ

1) Найти место для внесения изменения
2) Внести изменение
3) Проверить работает ли
4) Если нет, перейти к пункту 2

Почему этот способ не работает

Нет анализа исходной точки

В связи с тем, что нет анализа того, как система работает сейчас (до изменения), нет представления о том, как код изменения будет жить совместно с уже существующим кодом во всех возможных ситуациях.
Образный пример – мы имеем автомобиль, и он не ездит. При таком подходе, обычно рождаются решения вроде: приделать к автомобилю сбоку новый двигатель и зацепить цепной передачей за существующие колеса.

Пример из жизни: Было замечено, что некий продукт работает слишком медленно, если число отображаемых объектов превышает 6000. Разработчик не разобрался в существующем коде и приделал к нему кэш. При этом, поскольку кэш там уже был, получилось двойное кэширование, которое никак не повлияло на скорость работы, однако усложнило систему и внесло в нее ряд нетривиальных багов.

Когда анализ исходной точки не проводится, система начинает покрываться корявыми «наростами». А потом эти наросты – новыми наростами. В какой-то момент невозможно понять что система делает вообще.

Нет анализа конечных целей

Перед реализацией любого изменения необходимо четко ответить на вопрос – зачем оно делается. Ответы типа «так сказал заказчик» являются неверными. В случае нетривиальной задачи, этот ответ необходимо показать заказчику, чтобы убедиться, что он хотел именно этого.

Пример из жизни: Заказчиком была поставлена задача добавить к системе полнотекстовый поиск. Закзачик сделал акцент на том, что этот поиск позволит искать внутри описания объектов. При этом он не отметил, что поиск , работает в несколько раз быстрее, чем ранее применявшееся фильтрование в памяти, а также , что поиск может быть использован ВМЕСТО фильтрования, что повысит скорость работы системы в несколько раз. Пропуск фазы анализа конечной цели привел к тому, что полнотектовый поиск был приделан нарядус фильтрованием. И, хотя и позволил искать внутри описания объектов, никак не увеличил скорость работы. Именно это неверное решение привело к возникновению в будущем задачи про 6000 медленных объектов (см выше).

Нет гарантии, что изменение будет работать всегда

Изменение, внесенное согласно неверному процессу, как правило, проверяется только один раз и при идеальных условиях. Процесс не гарантирует, что оно будет работать в другом контексте (скажем, при переключении каких-то галочек, при изменении количества или типов объектов, если у пользователя изменятся права и т п.)

На самом деле, этот процесс гарантирует в точности обратное.

Образный пример – вы приносите в сервисный центр сломанный аудиоплэйер. Который включается через раз и иногда зависает. Ждете неделю. Потом вам его возвращают с диагнозом «неисправность не обнаружена». И все потому, что девочка-приемщик включила плэйер, подержала его включенным 2 минуты, и он за это время не завис.

Правильный способ внесения изменений

Вероятно, уже понятно, что неверный способ внесения изменений (также известный, как «запинывание») приводит к очень плохим результатам. Как минимум, все изменения , внесенные таким образом, потом вычищает и переписывает более опытный специалист, как максимум, проект убивается и становится не управляемым.
Теперь опишу правильный способ внесения изменений.

ДАО изменений

Вкратце, чтобы сделать изменение, вы должны точно знать что вы имеете сейчас (точка А), что хотите иметь в результате(точка Б) и как убедиться, что ваше изменение делает переход из точки А в точку Б и не делает ничего больше.

Процесс выглядит так:

1) Понять цели вносимого изменения
2) Описать, как должна работать система после внесения изменения
3) Понять, как система работает сейчас, какие аспекты она учитывает
4) Предложить метод перехода из состояния «сейчас» в состояние «надо»
5) Встать в позицию критика и объяснить, почему предложенный способ – полное говно
6) Вернуться в конструктивную позицию и перейти к пункту 4

На третьей-четвертой итерации вам не удастся выполнить пункт (5). Значит, пора переходить к реализации:

7) Реализовать выбранный метод, снабдив его комментариями, описывающими ваш ход мысли – почему такое изменение приведет к нужным результатам
8) Проверить реализацию с учетом всех аспектов, которые могут влиять на изменение (разные входные данные, секьюрити и т п). Здесь иногда надо написать юнит тест (если напрямую проверить не получается). А иногда, если требуется много проверок, можно написать инструкцию для тестера – включить в нее все действия, которые тестер должен выполнить.
9) Важно: если при проверках все идет не так, как было вами предсказано, НЕОБХОДИМО понять, почему это так. Ни в коем случае не списывать на случайность.
10) Задача считается сделанной, если вы видите, что переход в нужную точку состоялся и прекрасно понимаете как все работает после этого изменения.

Читать полностью...