Как стать автором
Обновить

Записки архитектора. Управление масштабными проектами, в которых не создаётся нового функционала

Время на прочтение15 мин
Количество просмотров3.9K

Думаю, все любят разрабатывать новый функционал приложений, сервисов, микросервисов и пр. И наверное, мало кто любит заниматься инфраструктурными работами. Например, портировать код на другую операционную систему или портировать код на другую микропроцессорную архитектуру, или же «отвязывать» вычислительную логику от графических интерфейсов (если вдруг приложение долгое время развивалось так, что отделение вычислений от графики никого не заботило)…

Тем не менее, инфраструктурные проекты тоже важны, и время от времени их приходится делать.

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

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

В этой статье мы, во-первых, разберём клинический пример, а во-вторых, поговорим про эффективные практики реализации масштабных инфраструктурных проектов.

Инфраструктурные проекты наиболее интересны в контексте больших – даже, пожалуй, очень больших! – приложений. Допустим, размера Фотошопа или какого-нибудь известного на весь мир компилятора. В этом случае в разработке приложения задействовано много команд, у которых, как правило, есть общий код. При этом код, разрабатываемый одной командой, зависит от кода, разрабатываемого другой командой, через вызовы функций или схожие низкоуровневые интерфейсы. На контрасте с «тяжёлыми» приложениями, инфраструктурные улучшения, например, небольшого web-сервиса размером в десятки тысяч строк проблем обычно не вызывают. Ибо над таким сервисом работает, скорее всего, одна команда. Причём у сервиса, вероятно, своя независимая вычислительная инфраструктура (допустим, пул виртуальных машин). И для других сервисов этот конкретный web-сервис доступен через очень высокоуровневый интерфейс (допустим, REST). Поэтому потребители сервиса даже не заметят, что внутри него происходят радикальные изменения, вроде портирования с Windows на Linux. Так что координации ни с какими другими командами, скорее всего, не потребуется.

Так что условимся, что мы здесь говорим про очень большие приложения – состоящие из миллионов строк кода. Также для определённости будем считать, что решается задача портирования приложения с одной операционной системы на другую: с Windows на Linux. Это ровно та ситуация, которая имеет отношение к обещанному мной «клиническому» примеру. Однако сказанное ниже будет справедливо и для многих других типов задач. И раз уж говорим о портировании с Windows на Linux, то условимся считать, что код портируемого приложения написан на довольно низкоуровневом языке, типа C++. Для совсем высокоуровневых языков конкретно задача портирования с одной операционки на другую выглядит менее интересно.

Я всегда считал, что проекты портирования кода на другую операционную систему или на другую аппаратную архитектуру – это относительно простые проекты. Мне самому посчастливилось участвовать в нескольких подобных проектах. И каждый из них только подтверждал тезис, что «портирование – это просто». Да, объем кода может быть большим; да, проект может быть относительно долгим. Но при этом довольно прямолинейным! В одном из проектов, в которых я участвовал, портировалось приложение «весом» более 10 миллионов строк кода. Однако даже такие объемы не кажутся проблемой, когда речь идёт о портировании. Поэтому я очень удивился, когда столкнулся с тем, что в весьма крупной компании годами не могут завершить портирование своего ключевого приложения.

Но прежде, чем перейти непосредственно к примеру, я поясню, почему считаю, что «портирование – это просто»? Во-первых, это довольно линейный процесс, про который понятно что и когда нужно делать. Во-вторых, весь код, который необходимо портировать, уже естественным образом распределён между разработчиками и тестировщиками: у каждого куска обычно есть свой владелец – пусть даже формальный, который ни разу этот код не "ковырял". Да, количество кода, приходящееся на одного владельца, может быть большим, но так или иначе весь код покрывается владельцами. Нужно только, чтобы каждый из них взялся за свой кусок.

Теперь к обещанному "клиническому" примеру. Приложение, которое нужно было портировать, имело вполне себе вменяемые размеры, по моим меркам. Сильно меньше 10 млн строк кода, но всё-таки больше миллиона. Как уже было сказано, портировать нужно было с Windows на Linux. Без графики, что важно, ибо портирование графических интерфейсов добавляет дополнительных сложностей, а там даже этих сложностей не было.

Под проект портирования была собрана отдельная небольшая команда, перед которой стояла только одна задача – портирование. Здесь важно не то, что под проект собрали отдельную команду, а то, что она была выделенная и была лишь небольшой частью полного коллектива, разрабатывавшего приложение. Конкретный ресурс под одну-единственную задачу. Такая практика очень распространена в той компании – транснациональной корпорации, кстати.

Может показаться, что если каждый сотрудник участвует не более, чем в одном проекте, то это очень даже хорошо. В некоторых условиях – так и есть. Человек не распыляется, полностью фокусируется на одной-единственной задаче…

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

Но вернёмся к примеру… Небольшому количеству людей поручили выполнить весь проект портирования, не занимаясь при этом ничем другим. Однако перед всеми остальными людьми, которые работали на той же кодовой базе и которых было в десятки раз больше, такая задача не стояла. То есть они «пилили» какие-то другие проекты. И в их интерес портирование кода на Linux не входило совершенно. Они занимались исключительно новым функционалом и продолжали писать код для Windows. Соответственно, получилась гонка: пока кто-то портирует конкретный модуль на Linux, кто-то другой льёт в этот модуль код, который подходит только для Windows. Через несколько лет – лет, Карл! – команда, занимавшаяся портированием, эту гонку всё-таки выиграла. В тестировании появилась полная рабочая сборка (билд – build) кода под Linux, и никто уже не мог влить код, который собирался бы только под Windows. Это сломало бы линуксовый билд. Но это был пока что только билд… А дальше надо было работать над стабильностью этого билда: чтобы функциональные тесты не падали и приложение, собранное под Linux, выдавало корректные результаты. А потом ещё и над производительностью надо было работать. И – сюрприз, сюрприз! – обоими этими направлениями работ снова занималась небольшая выделенная команда. И это опять получилась история на годы. Я даже не знаю, была ли она всё-таки закончена. Последний раз, когда я что-то слышал об этом портировании, оно длилось суммарно уже лет пять и было далеко от окончания. Таким образом, относительно простая по своей сути затея превратилась в многолетнюю эпопею с нерациональной тратой ресурсов. Ибо «гонки» разработчиков на общем коде приводят к дополнительной работе, которой можно избежать.

Хорошо: как не следует реализовывать масштабные проекты, мы увидели. А как следует? Приведу здесь сценарий, по которому на моей практике реализовывались успешные масштабные проекты. Для определённости снова будем говорить про портирование кода с Windows на Linux, чтобы была связь с приведённым примером. Однако на общие принципы этого сценария можно опираться и при решении многих других задач.

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

  2. Высокий менеджмент устанавливает сроки окончания работ. Допустим, год. Кстати, такой срок был выбран в самом первом проекте портирования, в котором я участвовал. Размер кодовой базы превышал 10 млн. строк; на каждого разработчика приходилось примерно 40 000 строк. Особой спешки не было. Предполагалось, что работа по портированию должна пройти в «фоновом режиме», не сильно отвлекая людей от проектов по созданию нового функционала

  3. Успешное завершение портирования должно попасть в годовые цели каждого привлечённого к портированию сотрудника. Каждого – от высоких менеджеров до стажёров. Понятно, что конкретные формулировки целей будут зависеть от должностей сотрудников. Например, директор по разработке может получить цель обеспечить портирование приложения/продукта целиком, а стажёр может получить цель портировать конкретный модуль кода

  4. На первоначальном этапе начинает работать небольшая команда. Да, на некоторых этапах масштабных проектов небольшие команды оправданны и даже оптимальны. Но при этом входящие в них люди могут параллельно трудиться и над другими проектами, если это вдруг целесообразно. Цель такой небольшой команды – подготовить почву: разработать общие практики портирования; написать какие-нибудь общие макросы; выявить наиболее распространённые проблемы и выработать для них решения; написать шаблоны для сценариев сборки; и т.п.

  5. Далее в дело вступают уже все разработчики. Цель на этом этапе – сделать так, чтобы весь код без ошибок собирался под Linux. Как только кто-то из разработчиков заканчивает портирование очередного модуля, он сразу делает так, чтобы успешное компилирование этого модуля под Linux было зафиксировано с помощью тестирования. С этого момента не получится влить в этот модуль правки, специфичные для Windows (если только они не будут «спрятаны» под специальные директивы, исключающие эти правки из компиляции под Linux). При попытке влить Windows-специфичный код, не экранированный соответствующими директивами, будет падать тестирование линуксовой сборки. В какой-то момент все модули будут успешно компилироваться под обе операционные системы, и тогда можно уже будет заняться линковкой (linking) под Linux. Как только удастся довести её до ума, этот результат также будет зафиксирован с помощью тестирования, и с этого момента всё приложение всегда будет успешно собираться под Linux.

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

Использование тестирования для закрепления промежуточных результатов…

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

  • в самом начале добавить в тестирование пустой сценарий сборки приложения под Linux. Так как собирается пустышка, то это будет успешно проходящий тест. Затем, как только удаётся портировать очередной модуль, добавлять инструкции для сборки этого модуля в сценарий. После каждого такого расширения тест на сборку по-прежнему будет успешно проходить. Изначально пустой сценарий будет постепенно расширяться, пока не вырастет до полного сценария сборки всего приложения

  • в самом начале написать полный сценарий сборки под Linux, но в каждый файл с кодом добавить директивы, которые будут исключать размещённый между ними код из сборки под Linux. Поначалу директивы будут охватывать весь код каждого файла. То есть мы хоть и имеем изначально полный сценарий сборки, собираться всё равно будет пустышка. Далее, по мере успешного портирования кода, из файлов постепенно будут удаляться «исключающие» директивы. Соответственно, всё больше и больше кода будет вовлекаться в реальную сборку.

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

Кстати, не только отдельные фрагменты некоторых файлов будут специфичными для той или иной операционки. Чаще всего будут даже целые файлы, специфичные для конкретной операционной системы. Какой-то файл может, например, входить в сценарий сборки под Windows, но при этом отсутствовать в сценарии сборки под Linux. Тем не менее, несмотря на наличие отдельных фрагментов кода или даже целых файлов, специфичных для конкретной операционной системы, подавляющий объём кода будет универсальным: будет одинаково успешно компилироваться и под Windows, и под Linux (и под десяток других ОС, если потребуется).

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

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

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

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

Выше я сказал, что портирование даже очень больших приложений – задача относительно простая и прямолинейная. Здесь я немного слукавил. Это не всегда так. Ну, то есть сам процесс портирования довольно прямолинейный, но на пути к конечному результату могут вскрываться довольно серьёзные проблемы. И некоторые из этих проблем будут требовать решения здесь и сейчас. Как правило, эти проблемы не имеют отношения именно к портированию. Они существовали и до него; и могли вскрыться каким-нибудь другим образом; но именно в процессе портирования впервые вылезли на поверхность. Такие "сюрпризы" – довольно обычное дело для масштабных инфраструктурных проектов. Приведу пару примеров из собственного опыта. Так как это уже не основная тема, а скорее, «байки в тему», то убираю их под спойлеры.

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

В некоторых приложениях могут быть реализованы сложные вычислительные алгоритмы, и какие-то из этих алгоритмов могут оказаться неустойчивыми. Результат работы таких алгоритмов сильно зависит от особенностей реализации. Допустим, что в реализации некоторого алгоритма вызывается какая-то библиотечная функция, предположим, «синус». Но под Linux это будет одна библиотека, а под Windows уже другая. Cинусы одного и того же числа, но посчитанные с помощью функций из разных библиотек, потенциально могут различаться, хоть и на ничтожную величину. Для устойчивых алгоритмов это не проблема, но вот на результат работы неустойчивого алгоритма эта «ничтожная величина» может сильно повлиять. И такое действительно иногда встречается на практике. Проблемы такого рода имеют хорошие шансы всплыть как раз во время портирования с одной операционной системы на другую (хотя могут всплыть и в некоторых других сценариях, конечно же). Идеальное решение в таком случае – замена неустойчивой версии алгоритма на устойчивую. При этом разработка устойчивой версии может оказаться непростой математической и технической задачей.

Сложные математические алгоритмы есть далеко не в каждом приложении. И даже если есть, то совсем необязательно среди них будут неустойчивые. Однако в той конторе, которую я взял за анти-пример, неустойчивых алгоритмов было существенное количество. Они, конечно же, всплыли во время портирования кода с одной операционки на другую. Легко представить, сколько боли проблемы с этими алгоритмами доставили небольшой команде «универсалов», выделенных на проект портирования. При этом им приходилось как-то ориентироваться и во многих других нюансах реализации приложения. Для огромных приложений это если не невозможно, то уж точно неэффективно. При грамотной реализации масштабных проектов каждая – ну или большинство – проблем должны попадать к наиболее подходящим адресатам. Если адресат неподходящий, то решение проблемы может занять много времени и при этом само может оказаться неподходящим. Например, баг может быть спутан с фичей, и вместо настоящего бага будет исправлена именно фича, тем самым превратившись в баг. И это совсем не искусственный пример; такие случаи действительно встречаются на практике. Сходу вспоминается история, связанная уже с другой конторой, когда в nightly-тестировании упал один нетривиальный тест и падение дали разбирать человеку, компетенция которого лежала в другой области. Он обнаружил, как ему показалось, существенную проблему в коде и «исправил» её. Тест стал проходить. Хорошо, что ревьюером исправлений оказался уже эксперт в нужной области. Исправления показались ему подозрительными, и он хорошенько вник в проблему, придя к выводу, что код уже изначально был корректным, а некорректным был сам тест. Таким образом, неудачного «исправления» удалось избежать благодаря ревьюеру, но было зря потрачено много времени.

Крутая фича, которая оказалась багом

В знаменитой транснациональной корпорации во время масштабной инфраструктурной работы выяснилось, что на одном из тестов приложение из-за ошибки показывало необоснованно хорошие результаты. Результаты были не просто «хорошие», они были выдающиеся. Но… в их основе лежал именно баг. В приложении срабатывала ошибочная фича, которая чисто случайно на этом конкретном тесте приводила к корректному и очень качественному результату. На других схожих тестах – если бы они были – эта ошибочная фича приводила бы к некорректным результатам.

Так как фича была ошибочной, её по-хорошему нужно было убирать. Без неё результат на тесте был бы по-прежнему корректным, но уже далеко не таким выдающимся. И всё бы ничего… но тест был не просто тестом, а это был очень популярный бенчмарк. И результат на этом бенчмарке был опубликован. То есть компания уже успела заявить на весь мир: «Смотрите, какие мы крутые! На таком важном общепризнанном тесте показали такие классные результаты!»

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

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

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

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

На этом пока всё. Всем эффективных управленцев! 😁

Ссылки на предыдущие "записки":
Записки архитектора. Как давать имена приложениям и сервисам
Записки архитектора. Чек-лист

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как нужно делать масштабные инфраструктурные проекты?
27.27% Силами специально выделенной команды3
72.73% Силами всех, кто трудится над приложением/сервисом/и пр.8
Проголосовали 11 пользователей. Воздержался 1 пользователь.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какова ваша сфера деятельности? (Основная, если вдруг трудитесь на несколько фронтов)
90.91% Разработка10
9.09% Тестирование1
0% DevOps0
0% Аналитика0
0% Другое0
Проголосовали 11 пользователей. Воздержавшихся нет.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Ваша должность в компании?
0% Стажёр (intern)0
0% Младший специалист (junior)0
45.45% Старший специалист (senior)5
36.36% Ещё более старший специалист :) (expert, principal, fellow и т.п.)4
9.09% Менеджер первого звена1
0% Старший менеджер0
9.09% Директор1
0% Старший директор и выше0
Проголосовали 11 пользователей. Воздержавшихся нет.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
+4
Комментарии1
2

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань