Я глупый и криворукий джуниор

16 сентября 2013

Вот многие программисты считают себя умными. Читают Хабр, слушают Радио-Т, рассуждают с умным видом про шаблоны ООП или там теории категорий. И любят делать умные решения. Ведь простые решения любая обезьянка написать может, а вот сложные… Почитывая мой бложик, кто-то из вас мог ошибочно подумать, что я тоже такой весь из себя шибко умный. Знаю там Haskell, книжек много читаю. В действительности, я очень глупый. И поэтому при решении задач стараюсь использовать как можно более простые решения.

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

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

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

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

Конечно, если данных действительно очень много, а расчеты действительно очень тяжелые, пользователь может быть не удовлетворен скоростью работы простого решения. Когда (и только когда) это случается, мы начинаем думать о том, как оптимизировать код. Но это совсем не означает использование кэша! Вспомним, какие эффективные методы оптимизации программ мы знаем. Покупка нового железа в данном случае, скорее всего, не прокатит. А вот распараллелить обработку запроса и/или производить вычисления приближенно могут оказаться приемлемыми решениями. Наконец, нередко можно показать пользователю сообщение «спасибо, ваш запрос обрабатывается» и произвести все вычисления в фоне. Когда ничего из этого не сработало, можно подумать о создании кэша.

По своей степени умности кэши тоже бывают разные. Самый тупой кэш — это кэш, который полностью инициализируется при старте приложения и сам обновляется после инициализации. Например, кэш должен периодически перечитывать данные из базы. Если это кэш данных, получаемых от стороннего приложения, это приложение должно неким образом стримить все имеющиеся данные. При этом работа кэша сводится к тому, чтобы подписываться на стрим и обновлять данные в оперативной памяти. Тут возникает традиционная проблема producer’а и consumer’а, самое тупое решение которой заключается в том, что consumer должен уметь регулировать скорость получения данных от producer’а.

Более умные кэши — это сквозные или write-through кэши. Кэш также полностью инициализируется при старте приложения. Чтение данных осуществляется из кэша. Запись производится как в кэш, так и в основное хранилище, например, СУБД. Чтобы кто-нибудь случайно не забыл обновить кэш при совершении записи, чтение и запись всегда производится через специальные функции-обертки (к вопросу «зачем нужна эта ваша дурацкая абстракция?»). Использование write-through кэша может препятствовать горизонтальному масштабированию.

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

Теперь забудем о кэшах. Вот, например, так получилось, что в мире Erlang нет общепризнанного достойного ORM. Поэтому что делают настоящие хакеры? Правильно, пытаются изобрести собственный, единственный правильный ORM. Как и любое умное решение, такой ORM приводит к массе увлекательных и трудных в диагностике ошибок. При их возникновении вы находите в логах какой-то непонятный стэктрейс, говорящий, что что-то где-то не поматчилось, начинаете разбираться в произошедшем по коду, а код-то не простой, а очень умный! Такого никогда бы не случилось, если бы вы использовали обычный epgsql.

Совсем умные решения также могут приводить к существенному падению производительности. Например, мне известно об одном популярном приложении, где также используется самопальный ORM (и самопальная CMS). Для генерации одной довольно простой веб-страницы этому приложению требуется выполнить тысячи SQL-запросов. Стоит ли говорить, что это происходит не очень быстро? Вот поэтому я по возможности стараюсь не писать своих фреймворков. Например, для распараллеливания вычислений в Erlang я использую связку из генераторов списков, а также функции proc_lib:spawn_link/1 и erlang:crc32/1. Возможно, я пишу на пару строк больше, чем в случае использования самопальной реализации какого-нибудь pmap, зато мой код прост, работает и может быть заточен для каждой конкретной ситуации. (Требуется ли свертка или нужно просто параллельно выполнить некие действия? Процессы нужно запустить и уничтожить один раз или нужно поддерживать пул?)

Другой пример. При старте приложения нужно извлечь данные по всем пользователям и что-то с ними сделать. Можно сказать users:get_all_ids/0, а затем сделать по одному вызову users:get_profile/1 для каждого юзера. Или можно пойти в базу с одним select-запросом, получающим сразу все данные. Я за первый вариант. Почему? Да потому что он проще! Допустим, спустя какое-то время, мы решили, что некоторые пользователи могут быть «забанены». Запись о них есть в базе данных, но приложение делает вид, что этих пользователей нет. Если мы использовали первый подход, то придется изменить только функцию users:get_all_ids/0, добавив туда условие where banned = false, после чего весь остальной код будет прекрасно работать. При использовании второго подхода код придется править в двух или более местах. Снова к вопросу о том, «зачем нужна эта ваша абстракция?». Конечно, первый вариант медленнее, но, скорее всего, нет ничего страшного в том, что приложение немного потупит на старте.

Допустим, мы пишем на Erlang и требуется что-то отладить или отпрофайлить. Настоящие хакеры вооружаются fprof или dbg и отважно бросаются в бой. Иногда это работает. Но в целом мне не очень нравятся эти инструменты. Часто fprof работает слишком медленно, а его вывод очень трудно читать. Dbg при неосторожном использовании может заспамить консоль нерелевантными сообщениями. И оба инструмента способны уронить наше приложение. Для отладки и профайлинга все еще прекрасно работает старый добрый отладочный вывод. Если код функции, которую мы хотим профайлить, небольшой, то можно тупо вбивать куски кода этой функции в REPL, оборачивая их в timer:tc/1, и смотреть, какой кусок сколько времени выполняется. Порой «ламерский» подход позволяет решить проблему быстрее и с меньшими усилиями, нежели подход с применением «правильных» инструментов.

Когда я читаю ТЗ, то не приступаю к работе до тех пор, пока не разберусь во всех деталях. Я же тупой, еще пойму что-нибудь не так, потом все переписывать придется. Да и технический писатель мог схалтурить и чего-то не учесть. Вот, например, тут говориться про поле ввода комиссии. Может ли комиссия быть отрицательной? С какой точностью она задается? Может ли она быть null? Если пользователь не ввел комиссию, нужно бросить исключение или использовать значение по умолчанию? Чему равно это значение? Я не боюсь показаться техническому писателю глупым. Наоборот, я в некотором роде горжусь своей глупостью. И, кто знает, может, в следующий раз он напишет ТЗ, по которому не будет возникать вопросов.

Я не в состоянии запоминать много информации, поэтому записываю ее в бложике, чтобы не забыть. И мои мысли недостаточно структурированы. Написание постов в блоге позволяет их структурировать. Мне не дано во всех деталях запомнить содержимое книги объемом 1000 страниц, поэтому я и не пытаюсь. Собственно, а зачем? Вместо этого я запоминаю, что вообще было в прочитанной книге. Когда мне что-то из этого понадобится, я буду знать, где искать. Знать, где найти информацию, не менее ценно, чем помнить ее, зато намного проще.

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

По тем же причинам при работе с РСУБД я активно пользуюсь различными constraint’ами и транзакциями. Базы данных нынче большие и сложные, а программисты — глупые. Если есть возможность минимизировать вероятность ошибки, зачем ею пренебрегать?

В общем, не старайтесь казаться умными! Наоборот, гордитесь своей тупостью и постоянно напоминайте окружающим, что вы глупы. Я лично в последнее время так и говорю своим коллегам: «Я же тупой и криворукий джуниор. Поэтому мне нужно объяснить во всех подробностях … / Что именно ты имеешь в виду, говоря … / Расскажи мне, пожалуйста, как …».

Дополнение: Алсо grey-olli мне тут подсказывает, что умные решения обычно существенно уступают простым в плане безопасности. Не могу с этим не согласиться. Еще писать как можно более простой код хорошо тем, что когда вам репортят багу, которую не удается воспроизвести, вы с хорошей степенью уверенности можете сказать, что репортер сделал что-то не так (ведь в таком простом коде почти невозможно ошибиться!) и попросить его описать способ воспроизведения проблемы.

Дополнение: А еще я просто ужасно ленивый

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.