вторник, 3 февраля 2009 г.

Defensive Design. Откуда берутся сбои

Так сложилось, что если о всяких паттернах и архитектурах на собеседованиях соискатели более или менее в состоянии поддержать разговор, то как только речь заходит о вариантах использования Debug.Assert(…), сразу же начинается плавание вокруг да около. Нет, я не говорю, что никто не может сформулировать основных положений «оборонительного проектирования», просто очень редко случаются действительно «концептуальные» беседы: как правило, приходится поднимать знания, которые присутствуют где-то на уровне подсознания на более высокий уровень. Поэтому я попробую организовать небольшую шпаргалку, дабы помочь коллегам.

Введение

Для начала вспомним знаменитый Закону Мёрфи (в общем виде) применительно к разработке ПО: «если существуют условия, в которых программа ведет себя неверно, то такие условия обязательно проявятся». Это может быть неверный пользовательский ввод, сбои в железе, рассогласование протоколов или же ошибки внутри самой программы. Так вот, оборонительное проектирование и борется со всевозможными проявлениями Закона Мёрфи – эта техника обороняет систему от того, что «никогда не может случиться».

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

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

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

Ниже я попробую собрать информацию о таких вот рекомендациях, использовав в качестве группирующей характеристики источники возникновения сбоев в системе.

Неверные данные

Этот принцип в частной форме очень хорошо знаком всем UI-программистам: пользователь всегда глуп. Никогда не стоит наедятся, что он введет емейл-адрес в правильном формате или будет аккуратно следить за максимально допустимым значением для какого-то поля. Поэтому программа должна защитить себя сама от подобных глупостей. Для этого UI-программисты используют всевозможные контролы-валидаторы. А более продвинутые коллеги реализуют целые подсистемы валидации данных.

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

В общем случае, есть три правила обработки входных данных:

  1. Проверке подлежат все данные из внешних источников;
  2. Проверке подлежат все входные параметры для данного модуля;
  3. Должна существовать четкая стратегия обработки неверных данных.

Плохой дизайн, грязный код

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

  1. Неоправданная сложность – потенциальный источник проблем (YAGNI, KISS, DRY);
  2. Повышение абстракции помогает изолировать проблемы (SOLID);
  3. Всестороннее тестирование, особенно на граничных и неверных условиях поможет выявить проблемы на ранних этапах (Software Testing);
  4. Чем меньше кода пишется с нуля – тем меньше в нем возникает ошибок (Code Reuse, Metaprogramming);
  5. Инспекция кода поможет выявить проблемы на уровне кодирования (Code Review, Code Audit).

Legacy-код, сторонние компоненты

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

Но даже хорошо написанный код унаследованных компонентов может стать источником проблем при использовании. Тривиальным примером является использование 16-битных инструкций в коде, который запускается на 64-битной операционной системе. Особенно, когда производители ОС заявляют о прекращении поддержки 16-битного API :) Еще одной знаменитой проблемой такого рода была «Проблема 2000».

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

В общем-то, все проблемы, связанные с legacy-кодом решаются установлением контроля над ним через изоляцию и рефакторинг. Изоляция позволяет нам максимально уменьшить зависимость и, как следствие, влияние проблемного компонента на систему в целом. А рефакторинг привносит в код недостающие элементы обороны.

Все эти техники подробно расписаны в хрестоматийной книге Working Effectively with Legacy Code (этот же материал, но в более компактной форме доступен в виде статьи).

Безопасность

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

Список рекомендаций для проектирования безопасных систем никогда не будет сформулирован окончательно. По сути, идет нескончаемая война между разработчиками и хакерами: война со своими стратегиями, героями, со своими победами и поражениями. И список рекомендаций было правильнее назвать списком тактик. Этот список будет пополняться по мере изобретения новых способов атак на системы. Приведу лишь самые основные из них:

  1. Любой код небезопасен, пока не доказано обратное;
  2. Безопасная обработка входных и выходных данных: доверяйте только проверенным источникам, ждите неприятностей на входе, никогда не сообщайте больше информации, чем нужно;
  3. Ограничивайте привилегии на уровне необходимого минимума для функционирования данного программного модуля;
  4. Неграмотная защита сродни ее отсутствию. Поэтому нужно защищаться от известных вам типов атак и всегда продолжать изучать новые типы атак;
  5. Пользуйтесь криптографией;
  6. Проводите регулярный аудит системы безопасности.

Паранойя

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

Например, при разработке программы для работы с домашней медиа-библиотекой на 40 человеко-часов вряд ли имеет смысл проводить регулярные security audits по критериям, которые обычно предъявляются к банковским системам. Или же проводить скрупулезное тестирование с использованием эвристических алгоритмов, как если бы вы разрабатывали систему для систем жизнеобеспечения или авиадиспетчерских. Но в то же время, вполне оправдано внимание к защите приложения от некорректных входных данных.

Заключение

Мы попытались сформулировать список типичных источников сбоев в системах и составить список типичных рекомендаций по их предотвращению. Конечно, многое было опущено из за всеобъемлющей природы затронутой темы. Например, я умышленно не упомянул о роли человеческого фактора в разработке защищенных систем: политические, эмоциональные, социологические. Тема безопасности программного обеспечения так же была затронута лишь поверхностно.

В следующих постах я продолжу «конспект», останавливаясь более детально на отдельных аспектах.

Опубликовать

11 комментариев:

  1. Есть мнение в ИТ, что дебаг.ассерт юзают крутые гуру, но никто из опрошенных гуру эту информацию не подтвердил, но и не опроверг :D

    ОтветитьУдалить
  2. Еще один момент про Debug.Assert. Имхо эта практика будет все менее популярна в связи с внедрением в массы TDD (юнит тестов). ТДД покрывает бизнес-метод тестами, в т.ч. тестами на то, как метод будет реагировать на некорректные значения параметров. Т.е. ТДД стимулирует исправлять код метода, чтобы тесты проходили. А Дебаг.ассерт - это своего рода "юнит тест лайт", внедренный в код метода, т.е. это не стимулирует делать код лучше, а только засоряет код описаниями, когда методу будет фигово :-)
    Пример: Функция, которая делит х на у:
    private decmal Div(decimal x, decimal y) {
    return x/y;
    }

    Вариант_1 с изменением кода для Дебаг.Ассерт:
    надо добавить в метод код: Debug.Assert(y != 0);

    Вариант_2 с изменением кода для юнит тестов:
    надо добавить в метод код: if (y == 0) {throw new MyBussinesException()};
    + добавить 2 юнит теста: нормальное деление на y!=0 и там, где кидается бизнес эксепшн.

    Имхо, 2-ой вариант намного лучше: а) повышается качество исходного кода б) исх. код не замусоривается лишним кодом в) в случае неправильного использования метода узнаем об этом мы (разработчики) на стадии прогона юнит-тестов, а не кастомеры в лайф версии.

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

    Но я бы пожалуй согласился, что Debug.Assert отойдет. Но не в пользу TDD, а в пользу Design by Contract, как более строгой и стройной форме этих самых ассертов.

    ОтветитьУдалить
  4. Кроме того, не стоит забывать о различной природе этих ассертов. TDD сродни синтетическим тестам, против тестов, которые работают в рантайме на живых данных.

    ОтветитьУдалить
  5. Спасибо за столь ценную информацию.

    ОтветитьУдалить
  6. Этот комментарий был удален автором.

    ОтветитьУдалить
  7. Исключительно полезная вещь Debug.Assert, конечно при умелом использовании.
    Навскидку дает дополнительную информацию о сбоях внутри алгоритмов и их ЧАСТЕЙ при проходе тестов и ручной отладке. Исключительно важно для многопоточных(многопроцессных) приложений в процессе их отладки и юниттестирования. Т.к. в простых и комплексных тестах иногда бывает достаточно сложно судить об успешности его прохождения (критерии или сложны или не совсем очевидны). Поэтому прогон при дебаговой конфигурации даст как минимум отсутствие результата на выходе (сработает Debug.Assert) за отведенное время, а нюансы увидим в логе. Или другой пример - логика конечных автоматов, допустимые переходы состояний я всегда проверяю через Debug.Assert. В сложных системах без пользовательского интерфейса иногда бывает чуть ли не единственным признаком того что что то идет не так, т.к. зачастую "защитный" код таких систем позволяет сохранить работоспособность и даже выдать результат, который не всегда рационально разбирать.
    Несколько соображений которые я держу в голове при использовании Assert.
    Через него проверяться должна ТОЛЬКО ЛОГИКА РАБОТЫ АЛГОРИТМА или его частей, и не в коем случае пользовательский ввод или информация пришедшая извне (другой модуль, система, источник). А вот после обработки верфикатором и возможно исправления его алгоритмами обработки ввода самое милое дело проверить эти самые алгоритмы через Assert (часто они просты, тест писать излишне, а проверить нужно). Юниттесты на все нюансы работы алгоритма и его частей не напишешь, а рефакторить до бесконечности тоже не всегда разумно.
    И еще не забывать, что кроме Debug.Assert, есть и Trace.Assert.
    Debug.Assert иногда можно нагрузить тяжелыми проверками, которых не будет в релизной конфигурации.
    Направление вывода Debug.Assert можно декларативно менять через конфиг, т.е., например, писать в лог файл.

    ОтветитьУдалить
  8. Непосредственно для логирования удобнее использовать более развитые вещи вроде log4net. А вот наглядно увидеть модальный диалог на ранних стадиях отладки (малой кровью), причем из любого потока выскочит (с умолчательной конфигурацией) бывает полезно.

    ОтветитьУдалить
  9. Более легковесный вариант: http://www.codeproject.com/KB/cs/designbycontract.aspx, только нужна определенная сила воли, чтобы использовать в проектах :).
    Большой плюс в таких библиотеках и оболочках - наглядность исходников.
    Здесь можно посмотреть хороший пример использования DesignByContract - "NHibernate Best Practices with ASP.NET, 1.2nd Ed.", http://www.codeproject.com/KB/architecture/NHibernateBestPractices.aspx?msg=1527549#xx1527549xx

    ОтветитьУдалить
  10. приятно, что можно подробно почитать об интересующем вопросе, спасибо автору!

    ОтветитьУдалить