Русский
Русский
English
Статистика
Реклама

S.o.l.i.d

Recovery mode Понятнее о S.O.L.I.D

29.06.2020 08:16:31 | Автор: admin
Большинство разработчиков с разговорами о принципах архитектурного дизайна, да и принципах чистой архитектуры вообще, обычно сталкивается разве что на очередном собеседовании. А зря. Мне приходилось работать с командами, которые ничего не слышали о S.O.L.I.D, и команды эти пролетали по срокам разработки на многие месяцы. Другие же команды, которые следовали принципам дизайна и тратили очень много времени на буквоедство, соблюдение принципов чистой архитектуры, код-ревью и написание тестов, в результате значительно экономили время Заказчика, писали лёгкий, чистый, удобочитаемый код, и, самое главное, получали от этого кайф.

Сегодня мы поговорим о том, как следовать принципам S.O.L.I.D и получать от этого удовольствие.



Что такое S.O.L.I.D? Погуглите и получите 5 принципов, которые в 90% случаев описываются очень скупо. Скупость эта потом выливается в непонимание и долгие споры. Я же предлагаю вернуться к одному из признанных источников и хотя бы на время закрыть этот вопрос.

Источником принципов S.O.L.I.D принято считать книгу Роберта Мартина Чистая архитектура. Если у Вас есть время прочесть книгу, лучше отложите эту статью и почитайте книгу. Если времени у Вас нет, а завтра собес велком.

Итак, 5 принципов:

Single Responsibility Principle принцип единственной ответственности.
Open-Closed Principle принцип открытости/закрытости.
Liskov Substitution Principle принцип подстановки Барбары Лисков.
Interface Segregation Principle принцип разделения интерфейсов.
Dependency Inversion Principle принцип инверсии зависимости.


Разберём каждый из принципов. В примерах я буду использовать Java и местами Kotlin.

Single Responsibility Principle принцип единственной ответственности.


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

Сформулировать его можно так:

Модуль должен иметь только одну причину для изменения. Или: модуль должен отвечать только за одну заинтересованную группу.

Вот тут становится непонятно, поэтому мы обратимся к признанному первоисточнику книге Роберта Мартина, у которого есть отличный пример на этот счёт. Воспользуемся им.

Предположим, что существует какая-то система, в которой ведётся учёт сотрудников. Сотрудники интересны как отделу кадров, так и бухгалтерии. Для их нужд, в числе прочих, в сервисе EmployeeService есть 2 метода:

fun calculateSalary(employeeId: Long): Double //рассчитывает зарплату сотрудникаfun calculateOvertimeHours(employeeId: Long): Double //рассчитывает сверхурочные часы

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

Логично, что для расчёта зарплаты бухгалтерии может потребоваться учесть сверхурочные часы сотрудника. В таком случае, метод calculateSalary() может вызвать calculateOvertimeHours() и применить его результаты в своих формулах.

Окей, прошло полгода, и в отделе кадров решили поменять алгоритм расчёта сверхурочных. Предположим, раньше один сверхурочный час рассчитывается не по коэффициенту * 2, а стал по коэффициенту * 2,5. Разработчик получил задание, изменил формулу, проверил, что всё работает, и успокоился. Город засыпает, просыпается бухгалтерия. А у бухгалтерии ничего не поменялось, они считают зарплаты по тем же формулам, вот только в этой формуле теперь будут другие цифры, потому что calculateSalary() ходит в calculateOvertimeHours(), а там теперь по просьбе отдела кадров сверхурочные не * 2, а * 2,5. Упс

Теперь, если мы вернёмся к описанию принципа, всё станет намного понятнее.

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

Теперь, я надеюсь, стало понятнее.

Каким может быть решение проблемы выше?

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

Open-Closed Principle принцип открытости/закрытости.


Принцип гласит:

Программные сущности должны быть открыты для расширения и закрыты для изменения.

Возьмём пример из суровой реальности.

У нас есть приложение, и, предположим, довольно неплохое. В нём есть сервис, который пользуется внешним сервисом. Предположим, это сервис котировок ЦБ РФ. В простой реализации архитектура этого сервиса будет выглядеть следующим образом:



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

Как решить проблему и сделать так, чтобы при добавлении новой реализации не трогать старый код?

Конечно же, через интерфейсы. Стоит нам разделить объявление функциональности и её реализацию, как эта проблема будет решена. Теперь мы можем реализовать интерфейс FinancialQuotesRestService сколько угодно раз; в нашем случае, это будет FinancialQuotesRestServiceImpl и FinancialQuotesRestServiceMock. На тестовом стенде приложение будет запускаться по Mock-профилю, на продакшене оно будет запускаться в обычном режиме.



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



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

Liskov Substitution Principle принцип подстановки Барбары Лисков.


Принцип подстановки Барбары Лисков можно сформулировать так:

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

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

public interface FinancialQuotesRestService {    List<Quote> getQuotes();}

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

В нашем случае, контроллер будет выглядеть так:

@RestController("/quotes")@RequiredArgsConstructorpublic class FinancialQuotesController {        private final FinancialQuotesRestService service;        @GetMapping    public ResponseEntity<List<Quote>> getQuotes() {        return ResponseEntity.ok(service.getQuotes());    }}

Как мы видим, контроллер не знает, какая именно реализация будет реализована далее. Получит ли он котировки ЦБ, Сбербанка или просто моковые данные. Мы можем запустить приложение в любом режиме, и от этого работа контроллера не изменится.

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

Interface Segregation Principle принцип разделения интерфейсов.


Принцип можно сформулировать так:

Необходимо избегать зависимости от того, что не используется.

Например, мы решили реализовать CRUD-интерфейс, который будет поддерживать операции CRUD. И реализовать его во всех сервисах. Выглядеть интерфейс может так:

public interface CrudService<T> {        T create(T t);        T update(T t);        T get(Long id);        void delete(Long id);}

Окей, мы реализовали его в сервисе UserService:

@Service@RequiredArgsConstructorpublic class UserService implements CrudService<User> {    private UserRepository repository;        @Override    public User create(User user) {        return repository.save(user);    }    @Override    public User update(User user) {        return repository.update(user);    }    @Override    public User get(Long id) {        return repository.find(id);    }    @Override    public void delete(Long id) {        repository.delete(id);    }}

Далее, нам потребовалось реализовать класс PersonService, который мы тоже наследуем от CrudService. Но проблема в том, что сущности Person не удаляемые, и нам не нужна реализация метода delete(). Как быть? Можно сделать так, например:

    @Override    public void delete(Long id) {        //не реализуется    }

И это будет ужасно. Мало того, что мы имеем в классе мёртвый код, который выглядит, как живой, так его кто-то ещё может и вызвать, не зная, что удаление не произойдёт.

Что делать в такой ситуации?

Выделить реализацию CRUD без удаления в отдельный интерфейс. Например, так:

public interface CruService<T> {    T create(T t);    T update(T t);    T get(Long id);}

Да простит меня читатель за такое именование интерфейса, но для наглядности самое то.

@Service@RequiredArgsConstructorpublic class PersonService implements CruService<Person> {    private final PersonRepository repository;    @Override    public Person create(Person person) {        return repository.save(person);    }    @Override    public Person update(Person person) {        return repository.update(person);    }    @Override    public Person get(Long id) {        return repository.find(id);    }}

Теперь сущность Person нельзя удалить! PersonService реализует интерфейс, в котором не объявлено ничего лишнего. В этом и есть соблюдение Принципа разделения интерфейсов.

Dependency Inversion Principle принцип инверсии зависимости.


Код, реализующий высокоуровневую политику, не должен зависеть от кода, реализующего низкоуровневые детали. Зависимости должны быть направлены на абстракции, а не на реализации.

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

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

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

В общем, Принцип инверсии зависимости можно постулировать так:

Абстракции стабильны. Реализации нестабильны. Строить зависимости необходимо на основе стабильных компонентов. Стройте зависимости от абстракций. Не стройте их от реализаций.

Заключение.


Мы, разработчики, не только пишем код (и это лучшие моменты в нашей работе). Мы вынуждены его поддерживать. Чтобы эти моменты нашей работы не стали худшими, используйте принципы S.O.L.I.D. Использование принципов S.O.L.I.D, по моему опыту, окупается в течение одного спринта. Потом сами себе скажете спасибо.
Подробнее..

Перевод - recovery mode История возникновения CUPID (критика SOLID)

20.03.2021 16:14:02 | Автор: admin

Какие бы вы предложили принципы для современной разработки программного обеспечения?

На прошлом виртуальном митапе Extreme Tuesday Club мы обсуждали, не устарели ли SOLID принципы. Не так давно я толкнул шутливую речь на эту тему, так что один из организаторов митапа спросил у меня - раз я несогласен с SOLID, чем бы я его заменил. Так получилось, что я уже думал об этом какое-то время, так что я решил предложить пять своих принципов, из которых получился акроним CUPID.

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

Почему неправилен каждый элемент SOLID.

PubConf был изобретён как разновидность афтепати к конференциям NDC. В соответствии с именем, место происходит в пабе. Несколько спикеров выступают в стиле Ignite - 20 слайдов, 15 секунд на слайд, автопродолжение - и аудитория ревет, хлопает, мечет молнии и благодарит в соответствии с местом, где это происходит. Победитель что-нибудь получает, все здорово проводят время.

Несколько лет назад я был приглашён спикером на PubConf эвент в Лондоне. Мне нравится челлендж выступлений с ограничениями. Я долго думал про SOLID принципы Роберта Мартина, и мне показалось забавным опровергнуть каждый из этих принципов, пытаясь сохранить при этом серьёзное лицо. Также я хотел для каждого пункта предложить альтернативу.

Некоторые речи пишут сами себя: я понял, что могу использовать один слайд на каждый принципе, один, чтобы опровергнуть его, один, чтобы предложить альтернативу - и так 5 раз. Итого 15 слайдов, по 45 секунд на принцип. Добавь начало и конец - и вот мои 20 слайдов!

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

После конференции я выложил слайды на SpeakerDeck и целая куча людей ,которых я раньше не встречал, встретили в штыки сначала мою речь, потом детали моих слайдов, а потом и меня лично.

Посколько я никогда это не записывал, вот примерная схема того, как пошёл разгвор. Держите в голове, что на каждый принцип у меня было 15 секунд на то, чтобы его представить, 15 секунд на то, чтобы его опровергнуть, и 15 секунд на альтернативу. Готовы? Поехали!

Принцип единой ответственности (SRP)

SRP принцип утверждает, что код должен делать только одну вещь. Другое определение гласит, что код должен "иметь только одну причину для изменений". Я назвал это "Бессмысленный Слишком Общий Принцип" (в оригинале Pointlessly Vague Principle - прим. переводчика). Что вообще означает "одна вещь"? Является ли DataProcessor ETL (extract-transform-load - извлечение данных - их трансформация - их загрузка) одной вещью или тремя? Любой нетривиальный код может иметь любое количество причин, чтобы измениться, которые вы могли или не могли учитывать, так что, опять же, для меня это несёт не очень много смысла.

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

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

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

Принцип открытости-закрытости (OCP)

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

Это был мудрый совет во времена, когда код:

  • Было дорого изменять: Попробуйте поменять маленький кусок кода, а затем сделать компиляцию и линковку миллионов строк в C++ в 1990 году. Я подожду.

  • Рискованно изменять: у нас не было ни правил рефакторинга, не было IDE с нормальным рефакторингом (кроме Smalltalk), не было практик разработки.

  • Самое важное, что можно добавить: вы писали какой-то код, помещали его в систему контроля версий (если использовали её, то, скорее всего, использовали RCS или SCCS (подробнее о них тут - прим. переводчика)), а затем переходили к следующему файлу. Вы транслировали спецификацию в код, по одному кусочку за раз. Переименовывать вещи было редкой практикой, не говоря уже о переименовании файлов. CVS, которая стала вездесущей системой контроля версий, буквально забывала всю историю файла, если вы его переименовывали, поэтому переименованию было такой редкостью. Этот пункт легко проглядеть в век автоматического рефакторинга и CVS, основанных на целом наборе изменений (changeset-based version control.)

Сегодня, подходящий совет, чтобы менять код, звучит так: Меняйте код, если он должен делать что-то ещё! Звучит банально, но сейчас мы воспринимаем код, как, скорее, податливую глину, нежели чем в старые добрые деньки, когда код напоминал строительные блоки. Между спецификацией и кодом не было цикла обратной связи, в то время как сейчас у нас есть автоматизированные тесты.

В этом случае я вывел "Принцип Аккреции Круфта". (Cruft Accretion Principle) (Аккреция(лат. accrti приращение, увеличение от accrscere прирастать) повышение массы одного космического объекта за счет гравитационного притяжения. - прим. переводчика)

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

Принцип подстановки Лисков (LSP)

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

Однако, язык, который использует LSP ("подтипы"), в сочетании с тем, что большинство разработчиков объединяет вместе "подтипы" и "подклассы", и ожидание "желаемых свойств" означает, что он пытается аппелировать к моделированию сущностей из 1980 года, со всем его "является (is-a)" и "имеет (has-a)" видами наследования.

В контексте моделирования, когда мы нож используем в качестве отвертки, а многие объекты наследуются видами: "ведёт-себя-как (act-like-a)", "иногда-используется-как (sometimes-be-used-as)" или "подойдет-если-не-сильно-приглядываться (pass-off-as-a-if-you-squint)"; в этом контексте что мы действительно хотим, так это маленькие, простые типы, которые мы можем композировать в любые насколько угодно сложные структуры, и примириться со всеми нюансами, которые это вызовет. Мой совет, внезапно, "писать более простой код", о котором легко рассуждать.

Принцип разделения интерфейсов (ISP)

Из всех принципов этот разобрать - как два пальца об асфальт. По каким-то причинам, этот пункт вызвал больше всего споров, но как по мне, его развенчать проще всего. Во время исследований при подготовке речи, я обратил внимание, что этот паттерн появился, когда Роберт Мартин столкнулся с God object, во время работы над софтом в Xerox. Всё происходило в классе под названием Job. Его подход к упрощению был в том, чтобы найти все места, где он использовался, выяснить, какие методы "сочетаются друг с другом" и объединить их в отдельный интерфейс. Это принесло сразу несколько преимуществ:

  • Сбор связанных методов в разных интерфейсах показал все различные обязанности, которые выполнял класс Job.

  • Присвоение каждому интерфейсу имени, раскрывающего намерение, упростило понимание кода, чем просто работа с объектом Job, встречающимся то тут, то там.

  • Создана возможность разбить класс Job на более мелкие классы, реализующие свои интерфейсы. (Возможно, интерфейс им больше не нужен.)

Всё это имеет смысл, просто дело в том, что это не принцип. Это паттерн.

Принцип хорош в любом контексте: "Сначала пытайтесь понять, а потом быть понятым", "Будьте добры друг к другу".

Паттерн это стратегия, которая хорошо работает в определённом контекесте (God-класс). У неё есть преимущества (меньшие компоненты) и компромиссы (больше классов, которыми нужно управлять). Принципом было бы, скорее, "вообще не устраивайте в коде бардак, который к такому привёл!".

Так что моя позиция в споре была такая: если это и был принцип, то это был "Принцип Двери из Конюшни" (Stable Door Principle). (дверь, разделенная на две части - верхнюю и нижнюю. Представьте, как сверху в открытую часть выглядывает лошадка - прим. переводчика) Если у вас изначально были небольшие, основанные на ролях классы, то вы бы не оказались в ситуации, где пытаетесь декомпозировать этот огромный запутанный беспорядок.

Конечно, иногда мы оказываемся в таком положении. Тогда разделение интерфейсов - идеальная стратегия, чтобы хоть немного упорядочить бардак, наряду также с созданием тестами характеристик и другими советами Майка Фезарса из прекрасной книги Working Effectively With Legacy Code.

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

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

Реальным принципом должна была стать опциональная инверсия. Зависимость интересна нам только тогда, когда есть несколько способов её предоставления, и вам нужно сделать инверсию отношений только если вы считаете, что в будущем это станет необходимо. Это довольно высокая планка, а чаще всего вам просто нужен только метод main.

Если вместо этого вы присоединитесь к идее, что все зависимости всегда должны инвертироваться, то вы закончите с J2EE, OSGi, Spring или любым другим фреймворком "декларативной сборки", где сама структуризация компонентов - закрученный лабиринт конфигураций. J2EE заслуживает отдельного упоминания, что каждый тип инверсии - EJB, servlets, web domains, remote service locations, даже конфигурация конфигураций - должен принадлежать разным ролям.

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

Большинство зависимостей не нуждаются в инверсии, поскольку большинство зависимостей не являются одним из вариантов внедрения, это просто то, как мы делаем вещи на этот раз. Моё - думаю, уже неудивительное предложение - писать более простой код, нежели сосредотачиваться на переиспользовании.

Если они тебе не понравились, у меня есть ещё

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

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

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru