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

0x7E5 Рассуждения о главном

О себе

Приветствую всех. Меня зовут Вячеслав, работаю в IT 11 лет в направлении Android. Трогал и гладил динозавров в лице Android 1.5 и 1.6, прошел все этапы становления MVP MVVM Retrofit и многих других библиотек. Смотрел на свой старый код как на кучу г... много раз и все еще продолжаю изучать новое и развиваться. Мне удалось выучить не один десяток, не побоюсь этого слова, сильных ребят, с хорошим потенциалом и головой на плечах, в процессе обучения были сформированы правила и рекомендации, которыми я и хочу поделиться.

О статье

В последнее время сталкиваюсь с множество проектов разной сложности и вижу закономерную проблему. Начинающие программисты не видят ценности таких понятий как Clean Code, KISS и SOLID. Можно согласиться с тем что Clean Code - это далеко не для начинающих, однако считаю что в общих чертах, знание данного подход необходимо. Программисты среднего уровня - не в полной мере применяют данные подходы. Опытные программисты зачастую слишком сильно углубляются в детали и забывают о самом важном. Для начинающих: эта статья поможет собрать для себя правила, которым стоит уделить внимания.

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

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

Я не стану вдаваться во все аспекты разработки, больше времени будет уделено самим идеям и правилам, которым стоит уделить внимание во время разработки. Затрону некоторые современные библиотеки и решения в области реактивного программирования. Выскажу мнение в отношении архитектур и Clean Code.

Подходы

Clean Code

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

Начнем пожалуй с наиболее часто упоминаемого Clean Code. Желающие могут изучить данный материал за авторством Роберта Мартина, вдаваться же в детали не буду. Однако хочу вынести наиболее важный момент. Чистый код - подразумевает написание кода, который легко читаться и также легко дорабатываться (написать же при этом такой код зачастую довольно сложно). Во время обучения и работы - я всегда думаю о названиях функций и переменных, о целях классов и их назначениях. Было введено одно довольно интересное правило: Правило двух прочтений. Суть правила - если внимательно прочитав код 2 раза, кто-то не понял назначения либо реализации кода - это плохой код. Поставьте себя на место нового разработчика, или себя-же но через несколько лет. В любой ситуации код должен легко читаться. Код не должен быть замудренным однострочным решением, но и раздувать сортировку пузырьком на 100 строк тоже не стоит. Для особо сложных элементов всегда есть комментарии. Почему 2 раза? - первый раз мы вникаем в структуру, второй - в логику, обе пункта должны быть прозрачными для читающего. Как же добиться такого кода.. Начинающие программисты редко уделяют внимание довольно простой вещи - именованию, ведь оно отвечает за половину от читаемости кода. Всем понятно что делает функция transformDateToString и мало кто определяет назначение функции transDTS. Не все понимают что больше кода - не значит хуже, и меньше кода - не всегда хорошо. Никогда не измеряйте качество кода его количеством, кода должно быть достаточно для решения задачи и сохранения читаемости. Именно такие мелочи зачастую становятся преградами в понимании кода. Не стоит бояться длинных имен, не стоит недооценивать важность комментариев. Не забывайте: если это очевидно сейчас - это не значит что оно останется очевидным позже.

KISS

Таким образом мы плавно переходим к KISS (keep it simple, stupid). Как бы весело и немногозначно звучал этот принцип, я рекомендую ставить его на одно из первых мест при разработке ПО. Сделайте свой код настолько простым - насколько это возможно, это упростит жизнь, вам, вашим коллегам а может и следующему программисту на проекте. И вот тут я хочу отметить частую ошибку программистов среднего и старшего звена. В попытках следования таким направлениям как SOLID, многие забывают, что код, хоть с ним и работает машина, пишут все-же люди, и в первую очередь код должен быть читаемым. Не стоит излишне усложнять код.

    interface Factory<out T> {       fun create(): T    }    typealias PrinterFun = (String) -> Unit    interface PrinterFactory : Factory<PrinterFun>    interface MessageFactory : Factory<String>    interface MessagePrinter {       fun print(pf: PrinterFactory, mf: MessageFactory)    }    class PrinterFactoryImpl : PrinterFactory {       override fun create(): PrinterFun = ::print    }    class MessageFactoryImpl : MessageFactory {   companion object {       const val DEFAULT_MESSAGE = "Hello World"   }   override fun create(): String = DEFAULT_MESSAGE   class MessagePrinterImpl : MessagePrinter {       override fun print(pf: PrinterFactory, mf: MessageFactory) {           pf.create().invoke(mf.create())       }   }   class ImplProvider {       private val impls = HashMap<KClass<out Any>, Any>()       fun <T : Any> setImpl(clazz: KClass<T>, t: T) {           impls[clazz] = t       }       fun <T : Any> getImpl(clazz: KClass<T>): T {           return (impls[clazz] as? T) ?: throw Exception("No impl")       }   }   fun main(args: Array<String>) {       val implProvider = ImplProvider()       implProvider.setImpl(PrinterFactory::class, PrinterFactoryImpl())       implProvider.setImpl(MessageFactory::class, MessageFactoryImpl())       implProvider.setImpl(MessagePrinter::class, MessagePrinterImpl())       implProvider.getImpl(MessagePrinter::class)               .print(implProvider.getImpl(PrinterFactory::class),                       implProvider.getImpl(MessageFactory::class))   }

Много ли найдется желающих дорабатывать ТАКОЙ Hello world? Чем менее сложный код - тем легче его дорабатывать.

    class TimeFormatter {       private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())       fun formatTime() = timeFormat.format(Date())    }

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

SOLID

Вот мы и дошли до бича современной разработки: SOLID! В понятие вложено довольно большой объем знаний и понятий, но степень важности некоторых очень сильно недооценивают, а способы решения иных - слишком сильно возводят в абсолют. Конкретно данному набору принципов я бы хотел уделить особое внимание. Для начинающих этот набор выглядит как монстр и становится стеной непонимания, для средних - опорой, для профессионалов - постулатом, истинна же не в том как этим орудовать, а скорее в понимании для чего это нужно. Чтобы лучше что-то понять, нужно увидеть границы и исключения. Если же всё время показывать как надо, мы никогда не поймем а как НЕ надо, так что дальше мы разберем каждый пункт с примерами хорошего и плохого использования.

S - single responsibility

[WIKI] Принцип единственной ответственности (single responsibility principle). Для каждого класса должно быть определено единственное назначение. Все ресурсы, необходимые для его осуществления, должны быть инкапсулированы в этот класс и подчинены только этой задаче.

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

Принцип разделяй и властвуй, в целом кажется довольно простым - пиши классы под определенные задачи, полезно и практично, однако зачастую можно столкнуться с паранойей. В своей практике встречал ситуацию когда в пакете утилит было около 20 классов с 1-2 методами (TextEditUtils, TextTransformUtils, TextConcatUtils и тд) - почему бы не объединить в TextUtils так и осталось загадкой. Не возводите этот принцип в абсолют, у всего есть границы, даже у безумия. Но и не стоит забывать что GOD-CLASS тоже плохо. Хоть и решение таких вопросов остается на совести разработчика, я не могу дать точных метрик и ограничений, так как каждый случай уникален. Ориентируйтесь на общий объем и связность. Если же взглянуть на второй вариант трактовки - возможно станет чуть более понятней. Проектируйте ваш код таким образом, чтобы причиной его изменить - могла быть только одна определенная задача. На примере выше, класс утилита для работы с текстом может иметь только одну логическую причину измениться - модификация взаимодействия со строками (добавление новой утилиты для удаления цифр в строке, удаление неиспользуемого метода и иные задачи относящиеся к манипуляциям текстом).

O - openclosed

[WIKI] Принцип открытости/закрытости. Программные сущности должны быть открыты для расширения, но закрыты для модификации.

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

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

open class UiComponent() {   var mode : Int = 0   fun showTextAndImage(text:String, image: Image){       mode = 0       ...   }   fun showButton(text:String, action: Runnable){       mode = 1       ...   }   ...}

Фокусы же начались после того, как был написан довольно просто класс-наследник:

class MyUiComponent(): UiComponent(){   fun doMagic(){mode = 3}}

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

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

L - Liskov substitution

[WIKI] Принцип подстановки Лисков. Объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы. Производный класс должен быть взаимозаменяем с родительским классом.

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

Мы писали довольно крупное приложение с возможностью скачивания файлов. Изначально это было одно место в коде и просто ссылка на файл. Не долго думая был реализован класс Downloader с функцией downloadFile(url). Позже появились новые типы файлов, вместе со ссылкой нужно было передавать параметры и хедеры для запроса, а для некоторых файлов нужно было еще дешифрование. По итогу был получен Downloader с кучей лишних функций на скачивание каждого типа файлов, а расширение или доработка становились адом. Решение (в упрощенном виде) было в вынесении абстракции Downloadable:

class DownloadManager() {   fun download(downloadable: Downloadable) {       val stream = downloadable.openStream()       val file = File(downloadable.getFileName())       //логика записи в файл   }}interface Downloadable {   fun openStream(): InputStream   fun getFileName(): String}class SimpleDownloadableFile(val name: String,                              val url: String) : Downloadable {   override fun openStream() = URL(url).openStream()   override fun getFileName() = name}class HeaderFile(val name: String,                  val url: String,                  val headers: Map<String, String>) : Downloadable {   override fun openStream(): InputStream {             /*формирование запроса и получении потока*/    }   override fun getFileName() = name}

Таким образом за счет данного принципа мы ушли от общих проблем скачивания (за счет подстановки объектов как имплементацию интерфейса) к частным задачам получения потока для каждого конкретного случая (по урл, по урл + хедеры и тд)

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

interface Somethinginterface SomethingSpecific : Somethinginterface WritableSomething : SomethingSpecific {   fun writeToFile()}interface GetableWritable<T> : WritableSomething {   fun obtain(): T}abstract class ObtainableFile(val name: String) : GetableWritable<File> {   override fun obtain() = File(name)   override fun writeToFile() = obtain().write(getStream())   abstract fun getStream(): InputStream}class UrlFile(url: String, name: String) : ObtainableFile(name) {   override fun getStream(): InputStream = URL(url).openStream()}

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

I - interface segregation

[WIKI] Принцип разделения интерфейса. Много интерфейсов, специально предназначенных для клиентов, лучше, чем один интерфейс общего назначения.

Один из самых сложных в понимании принципов. И самое сложное в данном принципе - это понять а кто есть клиент и осознать что зачастую мы сами и являемся клиентами. Вторая же сложность - это осознание значения слова интерфейс, которое зачастую воспринимается буквально interface / abstract class.

Смысл слова интерфейс в названии стоит воспринимать как точка доступа для более четкого осознания сути принципа. Точкой доступа может быть огромный класс на 1000 строк, но лишь с одной публичной функцией, а может быть обычный java interface, имплементацию которого мы скрыли.

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

На самом же деле принцип довольно легко выводится из предыдущих принципов. Предоставляй интерфейс отдельной функциональности а не всех, вытекает из принципа S (Single responsibility). Open-close же говорит о том что не стоит давать доступ ко всему и стоит либо верно организовывать доступность методов и параметров либо выделить абстракцию. Liskov substitution же обязует такую абстракцию быть функциональной и расширяемой.

D - dependency inversion

[WIKI] Принцип инверсии зависимостей. Зависимость на Абстракциях. Нет зависимости на что-то конкретное.

Каждый раз, вспоминая этот принцип, я чувствую боль. Самый недооцененный и в то же время заезженный принцип. Для правильного понимания и использования данного принципа необходимо максимально четкое понимания причины его существования. Причин же в целом можно выделить много, но я остановлюсь на двух. Первая: следую принципу single responsibility, большая часть логики разбита по классам и нам необходимо объединить логику работы разных классов в одном (допустим класс для работы с базой данных и класс для работы с сервером, должны быть в классе для работы с данными, например, запросить данные с сервера и положить в базу). Вторая: тестируемость. Вопрос тестирования стоит рассматривать отдельно однако для полноценного тестирования нам необходимо заменять части логики, в данном случае используя принцип Liskov substitution мы можем подменить, к примеру, реализацию работы с сервером на ее виртуальный аналог с фиксированными результатами на определенные запросы.

Рассмотрим простой пример: нам необходимо получить с сервера данные и сохранить их в файл. Следуя принципам выше у нас получиться примерно такой код:

open class ServerManager {   open fun getData(): String = "запрос на сервер"}open class CacheManager {   open fun saveData(data: String) {/*сохранение в файл/базу данных */}}class DataManager{   fun getDataAndCache(){       val data = ServerManager().getData()       CacheManager().saveData(data)   }}

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

Самым древним и простым способом реализации данного принципа - является способ передачи зависимостей через конструктор. Модифицируем DataManager из примера выше:

class DataManager(private val serverManager: ServerManager,                  private val cacheManager: CacheManager) {   fun getDataAndCache() {       val data = serverManager.getData()       cacheManager.saveData(data)   }}

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

Согласно Clean Architecture стоило бы выделить интерфейсы для каждого из классов менеджеров, однако это усложнило бы последующую разработку. Приведу пример идеального решения для ознакомления:

interface ServerManager {   fun getData(): String}open class ServerManagerImpl : ServerManager {   override fun getData(): String = "запрос на сервер"}interface CacheManager {   fun saveData(data: String)}open class CacheManagerImpl : CacheManager {   override fun saveData(data: String) {       /*сохранение в файл/базу данных */   }}interface DataManager {   fun getDataAndCache()}class DataManagerImpl(       private val serverManager: ServerManager,       private val cacheManager: CacheManager,) : DataManager {   override fun getDataAndCache() {       val data = serverManager.getData()       cacheManager.saveData(data)   }}fun foo(){   val dataManager: DataManager = DataManagerImpl(           ServerManagerImpl(),           CacheManagerImpl()   )   dataManager.getDataAndCache()}

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

Реализаций данного принципа довольно много (Dagger, Koin, ServiceLocator и тд), однако не стоит и перебарщивать. Зачастую можно заметить, как непонимание первопричин, приводят к появлению внедрения странных зависимостей:

interface TextProvider {   fun getText(): String}class SimpleTextProvider(private val text: String) : TextProvider {   override fun getText(): String = text}class Printer(private val textProvider: TextProvider) {   fun printText() {       println(textProvider.getText())   }}fun main() {   Printer(SimpleTextProvider("text")).printText()}

В данном примере вместо простой передачи текста был реализован класс, предоставляющий текст, далее согласно принципам SOLID выделен интерфейс, и проведен процесс Dependency injection. Однако очевидно что в данном случае мы получаем излишнюю функциональность и вырожденность кода. Гораздо проще передать текст для печати напрямую. Это и есть пример внедрения зависимостей ради внедрения, как можно заметить - излишнее стремление к совершенству лишь усложняет код и делает его трудно расширяемым и абсолютно противоречивым принципу KISS.

Самой большой проблемой данного подхода является определения звисимости, непонимание причин внедрения и целей приводит к тому, что программисты начинают внедрять всё. Нужно четко понимать цель внедрения - ослабления связанности и повышение тестируемости и не делать внедрение ради внедрения. Те же кто свято верят в постулат внедря всё и везде лишь делаю код абсолютно несвязанным и нечитаемым, усложняя работу себе и другим, забывая что полное отсутствие связности гораздо хуже слабой связности. Нет необходимости внедрять связанные компоненты (к примеру для Андроида - нет необходимости во внедрении Adapter-а, если сам адаптер не нуждается в зависимостях, просто используем конструктор и не мудрим).

О Важности архитектур

Начну пожалуй с того, что важности архитектуре приложения, к сожалению, не придают достаточного внимания. Понимание же смысла архитектуры оказывается важным пунктом в написания стабильного и качественного кода. На практике часто встречаются люди, решившие что архитектура это всего лишь набор правил или классов которые нужно реализовать. Хоть это и не далеко от истины - однако понимание назначения классов играет очень важную роль. Само же понятие и потребность в архитектуре - напрямую вытекают из рассмотренных выше подходов. Разделив наш код на классы для выполнения поставленных задач, необходимо правильно объединить и организовать данный код, сами же классы должны быть реализованы для выполнения достижения строго определенных целей - это по сути и есть архитектура: организация и целенаправленность кода. Известные архитектуры (MVP, MVVM и тд) это лишь набор правил, устоявшихся и сформулированных правил (сделать класс-модель, сделать класс-перзентер ). Важно понимать что архитектура позволяет значительно упростить и структурировать подход к разработке, выработать стратегию и правила. Известные архитектуры позволяют членам команды с большей эффективность работать над кодом, зная его структуру. Выбор же самой архитектуры должен осуществляться на основе поставленных задач.

Есть очень замечательная книга Clean architecture. И я ее торжественно ненавижу. Нет, не потому что она плохая или учит чему-то неправильному. К сожалению очень часто встречается Clean architecture головного мозга, чтение данной книги будет полезно для продвинутых программистов, для начинающих же это может стать постулатом и по итогу превратить в монстров, которые пишут внедрение зависимостей из примеров выше. Идеальную архитектуру написать можно - но работа с такой архитектурой будет занимать огромное количество времени. Тут стоит снова вернуться к примеру Hello World выше, аритектурненько ведь?

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

О том как думать

Меня часто спрашивают А как ты решаешь сложные задачи?, трудно ответить простыми словами. Важен подход, важен опыт, но алгоритм до боли известен: разбей сложную задачу на простые. На практике же, всегда нужно сводить сложные задачи к простым и понятным, искать наиболее простые решения. Боязнь ошибиться не должна останавливать от попыток. Даже самые сложные задачи можно свести к простым. Возьмем к примеру распознавание лиц, казалось бы довольно сложной задачей, а если подумать? Что есть лицо - 2 глаза, нос рот.. задачу найти лицо уже можно свести к задаче поиска частей лица ведь распознать нос гораздо проще чем лицо целиком. Как найти нос - задать шаблон и сравнивать. Как задать шаблон? Сделать фото носов, уменьшить, обесцветить и получить несколько шаблонных изображений. Таким образом даже самые сложные задачи всегда сводятся к более простым.

Не пытайтесь решить всё и сразу. Поэтапная разработка позволяет увидеть потенциал и ошибки на ранних стадиях разработки.

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

Отдых - очень важный фактор в нашей работе. Порой во премя отдыха (рекомендую душ) приходят самый лучшие решения нашим задачам. Иногда нужно просто выгрузить всё, иногда нужен свежий взгляд. Не забывайте про уточку (метод уточки/утёнка).

Заключение

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

Источник: habr.com
К списку статей
Опубликовано: 19.03.2021 18:12:20
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Программирование

Разработка мобильных приложений

Разработка под android

Solid

Kiss

Разработка

Категории

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

  • Имя: Макс
    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