Как работает рекрутер в IT? Есть ты, ты - специалист в разработке. Есть компания, которой ты нужен как сотрудник. Есть работник компании - рекрутер - собирающий информацию о разработчиках, после чего часто следует приглашение на собеседование. У тебя есть резюме, которое ты предлагаешь в качестве запроса на добавление тебя в базу компании. Тебе перезванивают, когда находится подходящая для тебя вакансия.
Я не даром отметил "ты предлагаешь" и "тебе перезванивают" - это ключевые моменты шаблона Observer. Единожды подписавшись на рассылку чего либо - ты будешь получать новости до тех пор, пока не решишь прекратить это. Будучи подписанным тебе не надо заботиться о том, где, как и когда появится информация - тебе перезвонят. "Не звоните нам, мы сами вам перезвоним!" Занимайся своими делами, а как только... так сразу...
Давай попробуем развить эту модель пошагово (test driven). Создай пустой проект и тест в нем. пускай он будет таким.
package myobserver; import static org.junit.Assert.assertSame; import org.junit.Test; public class RecrutingTest { private Vacancy newVacancy = null; @Test // проверяем что зарегистрированный кандидат получит уведомление public void testRegisterAndNotify() { // есть некий ректрутинг департамент Recruiter recruiter = new RecruitingDepartment(); // есть ты - потенциальный кандидат. // реализация интерфейса через анонимный класс в целях удобства тестирования. Candidate candidate = new Candidate() { @Override // когда нас уведомят о новой вакансии, // мы сообщим об этом тест-классу - так мы сможем // проверить уведомлял ли нас кто-то или нет. public void haveANew(Vacancy vacancy) { newVacancy = vacancy; } }; // мы регистрируемся у рекрутера recruiter.register(candidate); // а теперь рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); // вот тут мы и проверяем, как отработал наш рекрутинг assertSame("Кандидат не был информирован о новой вакансии", someVacancy, newVacancy); } }
Ты наверное заметил, что в коде достаточно много интерфейсов: Vacancy, Candidate, Recruiter? Да, я люблю интерфейсы - программирование с помощью интерфейсов дает невиданную гибкость и слабую связанность классов, что, в часто меняющихся условиях, очень выгодно.
Вот они, эти интерфейсы:
Кандидат - то есть ты. Интерфейс этот не определяет тебя полностью, он лишь говорит, что ты умеешь взаимодействовать с рекрутером и получать от него новые вакансии. Твои интерфейсы - это то, как ты можешь обращаться с окружающим тебя миром.
package myobserver; public interface Candidate { // кандидат умеет получать уведомления о вакансиях void haveANew(Vacancy vacancy); }
Рекрутер - это тоже специалист. Он может регистрировать нового соискателя (Candidate). Заметь, только такого, который сможет отреагировать на его уведомление (реализует метод haveANew(Vacancy)). Иначе зачем рекрутеру напрягаться, если никто все равно не обратит внимание.
Так же рекрутер может получать уведомление о новых вакансиях. Вакансии могут быть направлены ему с помощью метода addNew(Vacancy). Сделать это может кто-то, кто знает о такой возможности рекрутера.
package myobserver; public interface Recruiter { // через этот метод кандидат может подписаться на рассылку void register(Candidate candidate); // кто-то может через этот метод направить рекрутеру новую вакансию void addNew(Vacancy vacancy); }
А это пока пустой интерфейс - вакансия.
package myobserver; public interface Vacancy { }
Чтобы все компилировалось нам надо создать реализацию Рекрутера.
package myobserver; public class RecruitingDepartment implements Recruiter { @Override public void register(Candidate observer) { } @Override public void addNew(Vacancy vacancy) { } }
Если сейчас запустить тест, то он не пройдет (будет красненький). Потому я напишу самую простую реализацию, которая пришла мне в голову.
Писать как можно проще важно для качественного tdd. Часто, после первого написания кода, я стараюсь упростить его максимально. Но так, чтобы тест все еще проходил :).
Вот она, реализация.
package myobserver; public class RecruitingDepartment implements Recruiter { // это база кандидатов у рекрутера private Candidate candidate; @Override // в момент, когда кандидат обращается к рекрутеру public void register(Candidate candidate) { // рекрутер сохраняет кандидата // позже, рекрутер воспользуется этой ссылкой, // чтобы направить уведомление о новой вакансии this.candidate = candidate; } @Override // вот он момент истины! рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // и он тут же оповещает кандидату о ней candidate.haveANew(vacancy); } }
Запусти тест, он будет зеленый, а это значит - пора код отправлять в svn.
На данном этапе наш рекрутер работает только с одним кандидатом. В какой-то момент рекрутер набирается смелости и решает - я могу больше! Так появляется новый тест.
Замечу, что я немного порефакторил код и превратил anonymous class в inner class. Иначе в двух тестах наблюдалось бы дублирование.
package myobserver; import static org.junit.Assert.assertSame; import org.junit.Test; public class RecrutingTest { class SomeCandidate implements Candidate { // внутренний клас-кандидат всего лишь сохраняет вакансию. // так позже проверят информаровали ли кандидата Vacancy vacancy; @Override // когда кандидата уведомят о новой вакансии, он ее сохранит public void haveANew(Vacancy vacancy) { this.vacancy = vacancy; } } @Test // старый тест немного поменялся и использует теперь SomeCandidate public void testRegisterAndNotify() { Recruiter recruiter = new RecruitingDepartment(); // тут раньше был анонимный класс SomeCandidate candidate = new SomeCandidate(); recruiter.register(candidate); Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); // а тут проверялось поле тест-класса, а теперь поле кандидата assertSame("Кандидат не был информирован о новой вакансии", someVacancy, candidate.vacancy); } @Test // новый тест проверяющий оповещение двух зарегистрированных кандидатов public void testRegisterAndNotifyAll() { // есть некий ректрутинг департамент Recruiter recruiter = new RecruitingDepartment(); // один потенциальный кандидат. SomeCandidate candidate1 = new SomeCandidate(); // второй потенциальный кандидат. SomeCandidate candidate2 = new SomeCandidate(); // оба кандидата регистрируются у рекрутера recruiter.register(candidate1); recruiter.register(candidate2); // а теперь рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); // вот тут мы и проверяем, как отработал наш рекрутинг assertSame("Первый кандидат не был информирован о новой вакансии", someVacancy, candidate1.vacancy); assertSame("Второй кандидат не был информирован о новой вакансии", someVacancy, candidate2.vacancy); } }
Запускаем и видим, что тест не проходит (красный). Опять же делаем минимум правок, чтобы тест заработал.
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // на этот раз база кандидата по-круче будет private List<Candidate> candidates = new LinkedList<Candidate>(); @Override // в момент, когда кандидат обращается к рекрутеру public void register(Candidate candidate) { // рекрутер сохраняет кандидата в своем списке // позже, рекрутер воспользуется этим списком // чтобы направить всем новое уведомление candidates.add(candidate); } @Override // вот он момент истины! рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // он достает список и уведомляет всех всех всех for (Candidate candidate : candidates) { candidate.haveANew(vacancy); } } }
Ура! Тесты зеленые, а значит делаем commit!
Теперь я хочу добавить возможность удаления кандидата из списка рекрутера. Правда такое в IT не наблюдается, и раз уж ты попал в список, тебя будут уведомлять хчешь ты этого или нет. Но в виртуально мире все так, как я сказал, а потому очередной тест:
package myobserver; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import org.junit.Test; public class RecrutingTest { // ... тут старые тесты, они не менялись ... @Test // проверяем что удаленный кандидат перестает получать уведомление public void testUnregisterCandidate() { // все тот же ректрутинг департамент Recruiter recruiter = new RecruitingDepartment(); // кандидат SomeCandidate candidate = new SomeCandidate(); // регистрируемся у рекрутера recruiter.register(candidate); // и тут же передумываем recruiter.remove(candidate); // рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); // а кандидат ничего и не узнал assertNull("Кандидат получил уведомление, а не должен был", candidate.vacancy); } }
После написания этого теста нам придется расширить интерфейс рекрутера, ибо теперь он умеет делать выписку кандидата по желанию.
package myobserver; public interface Recruiter { // через этот метод соискатель может подписаться на рассылку void register(Candidate candidate); // кто-то может через этот метод направить рекрутеру новую вакансию void addNew(Vacancy vacancy); // новый метод! // кандидат может отказаться от услуг рекрутера void remove(Candidate candidate); }
Последнее, что надо сделать - это рекрутера расширить одним методом, пускай пока он будет пустым
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... все старое осталось без изменений ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // TODO Auto-generated method stub } }
Добились компиляции! Ура! Запускаем тесты. И видим что последний тест краснючий.
Добавим первое, что приходит в голову чтобы тест заработал.
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... тут все без изменений ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // согласен, глупость. Но тест ведь заработал после этой строки? // какой тест, такая и реализация :) candidates.clear(); } }
Почему я так сделал? А за тем, чтобы заставить добавить еще один тест, который проверит, что при удалении текущего кандидата с остальными ничего не произойдет. Мне кажется, тест вполне логичный. Только вот если бы я написал вместо
candidates.clear();
то, что ожидалось
candidates.remove(candidate);
то мой следующий тест был бы сразу зеленый. А это недопустимо. В tdd тест должен хоть раз проходить через красное состояние - так мы знаем, что он не холостой и проверяет именно то, что мы хотели.
Итак очередной тест
package myobserver; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import org.junit.Test; public class RecrutingTest { // ... все без изменений, менять старые тесты опасно ... @Test // проверяем, что после удаления перестает получать уведомление только // удаленный кандидат public void testUnregisterOnlyOneCandidate() { // есть некий ректрутинг департамент Recruiter recruiter = new RecruitingDepartment(); // раз кандидат SomeCandidate candidate1 = new SomeCandidate(); // два кандидат SomeCandidate candidate2 = new SomeCandidate(); // оба регистрируются recruiter.register(candidate1); recruiter.register(candidate2); // один передумал recruiter.register(candidate1); // тем временем рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); // второго проинформировали assertSame("Второй кандидат не был информирован о новой вакансии, хотя подписан", someVacancy, candidate2.vacancy); // а первый остался в неведении // assertNull("Первый кандидат после исключения из расписки не должен " + // "был получать уведомление, а получил", // candidate1.vacancy); // но мы это проверять не будем, потому как у нас уже есть тест на этот случай // повторяться в коде - плохая примета. } }
Запускаем. Красное! Ура! Теперь правка в реализации. Можно и дальше продолжать фигней страдать, но сейчас у меня умных тестов в голову не приходит, а потому пора остановиться и написать правильную реализацию.
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... тут ничего не менялось ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // находим кандидата в списке и удаляем candidates.remove(candidate); } }
Реализация слегка небезопасная и не предусматривает выписку не зарегистрированного ранее кандидата. Но реализация этого теста выходит за пределы шаблона Observer. Если хочешь - реализуй его самостоятельно.
Сейчас у нас уведомление происходит в момент добавления новой вакансии, то есть синхронно. В оригинально версии шаблона Observer, обновление состояния информатора (у нас это добавление вакансии рекрутеру) и информирование всех подписанных слушателей (у нас это кандидаты) происходит асинхронно. Реализуем подобное поведение у нас.
Представим, что рекрутер отправляет уведомление не в момент получения новой вакансии, а когда об этом ему скажет какой-то внешний раздражитель. Допустим раз в день (в соответсвии с правилами компании Х) каждый рекрутер должен оповестить всех кандидатов о новых вакансиях.
Изменение интерфейсной части в tdd самый интересный момент, особенно, когда уже есть ряд тестов. Тут мы всего лишь разделим один синхронный процесс (доавление вакансии с последующей нотификация всех) на два отдельных. Нового теста можем не добавлять, займемся для начала правкой старых.
В каждый тест, сразу после добавления новой вакансии стоит сделать дополнительный вызов нового метода. Итак в каждом тесте после строк
// а теперь рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy);
добавляем строки
// тут же оповещаем об этом recruiter.notice();
После этого в интерфейс рекрутера должен быть добавлен новый метод
package myobserver; public interface Recruiter { // ... старые методы без изменений // с помощью этого метода рекрутеру подается команда // оповестить всех кандидатов void notice(); }
Реализация немного усложнится
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // база кандидатов private List<Candidate> candidates = new LinkedList<Candidate>(); // рекрутеру надо временно хранить свои вакансии // для этого в него добавлен этот список private List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // кандидат обращается к рекрутеру public void register(Candidate candidate) { // ... тут ничего не меняется ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // ... тут так же ничего не меняется ... @Override // а когда рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // и он ее добавит в свой список вакансий vacancies.add(vacancy); // и все! // Напомню раньше он тут же оповещал кандидатов // вот так // for (Candidate candidate : candidates) { // candidate.haveANew(vacancy); // } } @Override // а тут пришла команда оповестить всех public void notice() { // он достает список и уведомляет всех всех всех... for (Candidate candidate : candidates) { // ...обо всем, что накопилось за это время for (Vacancy vacancy : vacancies) { candidate.haveANew(vacancy); } } // по логике потом он чистит список вакансий, // чтобы не дай бог не повторяться // vacancies.clear(); // но это уже после добавление теста, проверяющего этот факт } }
Теперь если запустить все тесты, они будут зеленые - мы ничего не поломали, но в то же время аккуратно изменили интерфейс. Самое время коммититься.
Вот где-то так и выглядит в чистом виде шаблон Observer. Если глянуть на UML из Википедии
А потом переписать на нашем языке
То можно увидеть они почти идентичны. Разница лишь в том, что у нас кроме методов добавления/удаления/нотификации кандидатов и есть еще метод добавления вакансии. Не беда.
Все что будет происходить дальше с шаблоном Observer имеет мало общего. Просто я хочу еще немного поэкспериментировать с кодом.
Есть один раздражающий для кандидата момент - всякий раз, когда придет время оповещения кандидатов, каждый из них получит полный список вакансий. Может будем очищать временный список после того, как отправим его всем? Вперед!
Вот тест
package myobserver; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import java.util.LinkedList; import java.util.List; import org.junit.Test; public class RecrutingTest { // ... inner class SomeCandidate не трогаем ... // а этот внутренний клас-кандидат сохраняет ВСЕ ваканси в список // так позже можно будет проверить как именно информаровали кандидата class VacanciesRecorder implements Candidate { List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // когда кандидата уведомляют о вакансии, он ее сохраняет public void haveANew(Vacancy vacancy) { vacancies.add(vacancy); } } // ... другие тесты мы не трогаем ... @Test // проверяем что зарегистрированный кандидат получит уведомление // но только по новым вакансиям public void testNotifyOnlyWithNewVacancies() { // все как обычно Recruiter recruiter = new RecruitingDepartment(); VacanciesRecorder candidate = new VacanciesRecorder(); recruiter.register(candidate); // рекрутеру упала новая вакансия и тут же информируем recruiter.addNew(new Vacancy() {}); recruiter.notice(); // очищаем список сохраненный у кандидата candidate.vacancies.clear(); // позже упали две вакансим и тут же информируем Vacancy someVacancy1 = new Vacancy() { }; recruiter.addNew(someVacancy1); Vacancy someVacancy2 = new Vacancy() { }; recruiter.addNew(someVacancy2); recruiter.notice(); // вот тут мы и проверяем, что получили новости assertSame("Кандидат должен был быть информарован двумя вакансиями", 2, candidate.vacancies.size()); assertTrue("Кандидат был информирован не о новых вакансиях", candidate.vacancies.contains(someVacancy1)); assertTrue("Кандидат был информирован не о новых вакансиях", candidate.vacancies.contains(someVacancy2)); } }
И он естественно не работает.
После того, как мы добавим одну единственную строку очистки временного списка в метод notice класса RecruitingDepartment, тест зазеленеет.
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... тут ничего не менялось @Override public void notice() { // ... тут тоже ... // а потом чистит список вакансий, чтобы не дай бог не повторяться vacancies.clear(); } }
Опля! Коммитимся!
Но как же! Все нажитое нечесным путем - все вакансии, после информирования пропадут. А как же новые кандидаты, им наверное было бы интересно получить список того, что раньше получали их коллеги.
Новый тест в студию!
package myobserver; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import java.util.LinkedList; import java.util.List; import org.junit.Test; public class RecrutingTest { // ... без изменний ... @Test // проверяем что новенький кандидат получит уведомление // по всем вакансиям, которые когда либо были добавлены в системе // даже если они уже высылались его коллегам public void testNotifyAllVacanciesForEachNewCandidate() { // все как обычно Recruiter recruiter = new RecruitingDepartment(); recruiter.register(new VacanciesRecorder()); // рекрутеру упала новая вакансия. тут же информируем Vacancy someVacancy1 = new Vacancy() { }; recruiter.addNew(someVacancy1); recruiter.notice(); // но тут зарегистрировался еще один кандидат. VacanciesRecorder candidate = new VacanciesRecorder(); recruiter.register(candidate); // и после упали еще две вакансии. Vacancy someVacancy2 = new Vacancy() { }; recruiter.addNew(someVacancy2); Vacancy someVacancy3 = new Vacancy() { }; recruiter.addNew(someVacancy3); // информируем всех recruiter.notice(); // проверяем, что наш новенький кандидат получил все "до копеечки" assertSame("Кандидат должен был быть информарован тремя вакансиями", 3, candidate.vacancies.size()); assertTrue("Кандидат был информирован не о всех вакансиях", candidate.vacancies.contains(someVacancy1)); assertTrue("Кандидат был информирован не о всех вакансиях", candidate.vacancies.contains(someVacancy2)); assertTrue("Кандидат был информирован не о всех вакансиях", candidate.vacancies.contains(someVacancy3)); } }
Тест естественно красный. Пошаманим над реализацией...
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // рекрутеру надо как-то отличать новых кандидатов от старых // тут будут хранитсья новые кандидаты private List<Candidate> newCandidates = new LinkedList<Candidate>(); // а старая база кандидатов - для тех, кто хоть раз получал уведомление private List<Candidate> candidates = new LinkedList<Candidate>(); // рекрутеру надо временно хранить свои новые вакансии private List<Vacancy> newVacancies = new LinkedList<Vacancy>(); // но так же ему надо складировать где-то вакансии, которые уже отправлялись private List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // в момент, когда кандидат обращается к рекрутеру public void register(Candidate candidate) { // рекрутер сохраняет кандидата в своем списке новых кандидатов newCandidates.add(candidate); } @Override // рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // и он ее добавит в свой список новых вакансий newVacancies.add(vacancy); } @Override // метод отписки от рассылки public void remove(Candidate candidate) { // удяляем кандидата из основного списка, если он там есть candidates.remove(candidate); // то же проделываем со списком новеньких newCandidates.remove(candidate); } @Override // а тут пришла команда оповестить всех public void notice() { // он достает основной список и уведомляет всех старичков... for (Candidate candidate : candidates) { // ...обо всем, что накопилось за это время for (Vacancy vacancy : newVacancies) { candidate.haveANew(vacancy); } } // после он переносит новые вакансии в список историю vacancies.addAll(newVacancies); newVacancies.clear(); // далее он информирует всех новеньких... for (Candidate candidate : newCandidates) { // ... абсолютно всеми вакансиями for (Vacancy vacancy : vacancies) { candidate.haveANew(vacancy); } } // после чего переносит новеньких кандидатов в основной список candidates.addAll(newCandidates); newCandidates.clear(); } }
Код заметно усложнился, но зато тесты зеленые :) Коммитимся.
Дальше меня смущает, что кандидат дергается иза за каждой новой вакансии. Хочу, чтобы он дергался раз и передавался ему список.
Снова меняется интерфейсная часть без добавления новой функциональности. Акуратно исправляем интерфейс а потом все места, в которых не компилируется
package myobserver; import java.util.Collection; public interface Candidate { // кандидат умеет получать уведомления о вакансиях void haveANew(Collection<Vacancy> vacancies); }
Изменился метод нотификации
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... тут без изменений @Override // а тут пришла команда оповестить всех public void notice() { // он достает основной список и уведомляет всех старичков... for (Candidate candidate : candidates) { // ...обо всем, что накопилось за это время candidate.haveANew(newVacancies); // *** } // после он переносит новые вакансии в список историю vacancies.addAll(newVacancies); newVacancies.clear(); // далее он информирует всех новеньких... for (Candidate candidate : newCandidates) { // ... абсолютно всеми вакансиями candidate.haveANew(vacancies); // *** } // после чего переносит новеньких кандидатов в основной список candidates.addAll(newCandidates); newCandidates.clear(); } }
и тестовые классы реализации кандидатов
package myobserver; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import java.util.LinkedList; import java.util.List; import org.junit.Test; public class RecrutingTest { // ... без изменний ... // внутренний клас-кандидат всего лишь сохраняет вакансию. // так позже проверят информаровали ли кандидата class SomeCandidate implements Candidate { // внутренний клас-кандидат всего лишь сохраняет вакансию. // так позже проверят информаровали ли кандидата Vacancy vacancy; @Override // когда кандидата уведомят о новой вакансии, он ее сохранят public void haveANew(Collection<Vacancy> vacancies) { assertEquals("Ожидается одна вакансия", 1, vacancies.size()); this.vacancy = vacancies.iterator().next(); } } // а этот внутренний клас-кандидат сохраняет ВСЕ ваканси в список // так позже можно будет проверить как именно информаровали кандидата class VacanciesRecorder implements Candidate { List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // когда кандидата уведомляют о вакансиях, он их сохраняет public void haveANew(Collection<Vacancy> vacancies) { this.vacancies.addAll(vacancies); } } // ... дальше все без изменений ... }
Тесты зеленые, значит ничего не поломали - коммит!
Но мне тут подозрение одно закрлось. Рекрутер информирует своих кандидатов своими оригинальными списками. Рекрутер отдает каждому кандидату ссылку на свой список, которым пользуется сам. Это нарушение инкапсуляции! Достаточно хитрый кандидат сможет удалить вакансию из списка и она больше никому не достанется. Так кандидат сможет повысить свои шансы попасть на собеседование.
Напишем тест выскрывающий эту дыру.
package myobserver; import static org.junit.Assert.*; import java.util.Collection; import java.util.LinkedList; import java.util.List; import org.junit.Test; public class RecrutingTest { // ... иннер-классы-кандидаты не менялись ... // а этот внутренний клас-кандидат удаляет ВСЕ ваканси из списка, которй ему вручает // рекрутер в надежде на то, что его шансы попасть на собеседование возрастут class VacanciesEraser implements Candidate { Collection<Vacancy> vacancies = null; @Override // когда кандидата уведомляют о вакансиях, он их удаляет public void haveANew(Collection<Vacancy> vacancies) { this.vacancies = vacancies; } public void clearVacnsiesList() { vacancies.clear(); } } // ... другие тесты не менялись ... @Test // Никто из кандидатов н может влиять на списки рекрутера public void testDoNotFixRevruterList() { Recruiter recruiter = new RecruitingDepartment(); // первый кандидант - хитрый, он удаляет все, что ему попадается в руки VacanciesEraser candidate1 = new VacanciesEraser(); recruiter.register(candidate1); // появилась вакансия, тут же оповестили Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy); recruiter.notice(); // появился второй кандидант VacanciesRecorder candidate2 = new VacanciesRecorder(); recruiter.register(candidate2); // но тут злостный кандидат почистил список! candidate1.clearVacnsiesList(); // прошла вторая фаза уведомления recruiter.notice(); // проверяем, что наш новый кандидат получил все - справедливость восторжествовала assertSame("Кандидат должен был быть информарован вакансией", 1, candidate2.vacancies.size()); assertTrue("Кандидат был информирован не о той вакансии", candidate2.vacancies.contains(someVacancy)); } }
Супер! Тест красный. Решается это копированием списков рекрутером до отправки.
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... тут ничего не менялось ... @Override public void notice() { for (Candidate candidate : candidates) { // заметь, тут идет вызов makeNewList candidate.haveANew(makeNewList(newVacancies)); } vacancies.addAll(newVacancies); newVacancies.clear(); for (Candidate candidate : newCandidates) { // заметь, и тут идет вызов makeNewList candidate.haveANew(makeNewList(vacancies)); } candidates.addAll(newCandidates); newCandidates.clear(); } // а это новый метод копирования списка private List<Vacancy> makeNewList(List<Vacancy> vacancies) { return new LinkedList<Vacancy>(vacancies); } }
Тест зеленый - коммитим!
Следующее и последнее, что я хочу это дать возможность кандидатам указывать какие вакансии их интересуют а какие нет. Для этой цели я введу новый интерфейс - резюме.
package myobserver; import java.util.Set; public interface Resume { // в резюме есть контактные данные кандидата Candidate getCandidate(); // и набор технологий, которыми владеет кандидат Set<String> getTechnologies(); }
Снова меняется интерфейсная часть, но тут так же добавляется новая функциональность. Потому я вначале незаметно поменяю интерфейс, добавивив резюме. А потом я сделаю возможным фильтрование вакансий по критериям в резюме.
Меняется и вакансия - в ней теперь так же будут указан набор технологий.
package myobserver; import java.util.Set; public interface Vacancy { // вакансия сожержит набор требуемых технологий Set<String> getTechnologies(); }
Естественно после этого тесты перестанут компилироваться, а потому добавим внутренний класс EmptyVacancy, который будет подходить абсолютно всем :)
package myobserver; import static org.junit.Assert.*; import java.util.Collection; import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.HashSet; import org.junit.Test; public class RecrutingTest { // ... другие иннер-классы не менялись ... // вакансия, которая подойдет всем кандидатам - пустая class EmptyVacancy implements Vacancy { @Override public Set<String> getTechnologies() { return new HashSet<String>(); } } // ... тесты без изменений ... }
А в тестах, везде, где раньше писали
// а теперь рекрутеру упала новая вакансия Vacancy someVacancy = new Vacancy() { }; recruiter.addNew(someVacancy);
Запишем так
// а теперь рекрутеру упала новая вакансия Vacancy someVacancy = new EmptyVacancy(); recruiter.addNew(someVacancy);
Хух! Компилится и более того все зеленое, а потому коммитим!
Следующая правка будет более болезненной
package myobserver; public interface Recruiter { // через этот метод соискатель может подписаться на рассылку // раньше регистрировался кандидат лично // void register(Candidate candidate); // но теперь как заявка передается резюме void register(Resume resume); // ... остальное без изменений ... }
Снова тесты не компилятся.
Вот как меняется класс реализуюзий рекрутера. Обрати внимание как все упоминания Candidate заменились на Resume + в методе рассылки вакансий кандидат извлекается из резюме.
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // рекрутеру надо как-то отличать новых кандидатов от старых // тут будут хранитсья резюме новых кандидатов private List<Resume> newCandidates = new LinkedList<Resume>(); // а старая база резюме кандидатов - для тех, кто хоть раз получал уведомление private List<Resume> candidates = new LinkedList<Resume>(); // рекрутеру надо временно хранить свои новые вакансии private List<Vacancy> newVacancies = new LinkedList<Vacancy>(); // но так же ему надо складировать где-то вакансии, которые уже отправлялись private List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // в момент, когда кандидат обращается к рекрутеру он передает свое резюме public void register(Resume resume) { // рекрутер сохраняет резюме кандидата в своем списке новых кандидатов newCandidates.add(resume); } @Override // рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // и он ее добавит в свой список новых вакансий newVacancies.add(vacancy); } @Override // метод отписки от рассылки public void remove(Candidate candidate) { // удяляем резюме кандидата из основного списка, если он там есть candidates.remove(candidate); // то же проделываем со списком новеньких кандидатов newCandidates.remove(candidate); } @Override // а тут пришла команда оповестить всех кандидатов public void notice() { // он достает основной список и уведомляет всех старичков... for (Resume resume : candidates) { // ...обо всем, что накопилось за это время // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(newVacancies)); } // после он переносит новые вакансии в список историю vacancies.addAll(newVacancies); newVacancies.clear(); // далее он информирует всех новеньких... for (Resume resume : newCandidates) { // ... абсолютно всеми вакансиями // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(vacancies)); } // после чего переносит резюме новеньких кандидатов в основной список candidates.addAll(newCandidates); newCandidates.clear(); } // а это новый метод копирования списка вакансий private Collection<Vacancy> makeNewList(List<Vacancy> vacancies) { return new LinkedList<Vacancy>(vacancies); } }
Теперь за тесты возьмемся. Естественно у нас перестали работать все методы рекрутера register.
Исправляеся легко. Там где было
// регистрируемся у рекрутера recruiter.register(candidate);
заменяем на
// регистрируемся у рекрутера recruiter.register(new CandidateResume(candidate));
И доавляем новый inner класс в класс теста - резюме кандидата, который вообще ничего не знает из технологий. Такие бывают - это многие студенты на первом курсе.
package myobserver; import static org.junit.Assert.*; import java.util.Collection; import java.util.LinkedList; import java.util.List; import java.util.Set; import java.util.HashSet; import org.junit.Test; public class RecrutingTest { // ... другие иннер-классы не менялись ... // это резюме кандидата, который ничего не знает class FresherResume implements Resume { // тут будем хранить кандидата private Candidate candidate; // кандидат вписывается в резюме в момент его создания public FresherResume(Candidate candidate) { this.candidate = candidate; } @Override // вернем кандидата если спросят public Candidate getCandidate() { return candidate; } @Override // в ответ на список технологий мы скажем что ничего не знаем public Set<String> getTechnologies() { return new HashSet<String>(); } } // ... тесты не менялись ... }
Ошибки компиляции должны пропасть. Теперь если запустим тест, то увидим что что-то пропустили. Это момент удаления кандидата. Там раньше было так
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... бла бла бла ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // удяляем кандидата из основного списка, если он там есть candidates.remove(candidate); // то же проделываем со списком новеньких newCandidates.remove(candidate); } }
А должно быть так
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... ничего не менялось ... @Override // метод отписки от рассылки public void remove(Candidate candidate) { // удяляем резюме кандидата из основного списка, если он там есть removeCandidate(candidate, candidates); // то же проделываем со списком новеньких кандидатов removeCandidate(candidate, newCandidates); } // так как добавляем мы кандидата по его резюме а удаляем по имени кандидата // то вот вам и метод private void removeCandidate(Candidate candidate, List<Resume> candidates) { Collection<Resume> resumeForRemove = new LinkedList<Resume>(); for (Resume resume : candidates) { if (resume.getCandidate().equals(candidate)) { resumeForRemove.add(resume); } } candidates.removeAll(resumeForRemove); } }
После этого тесты заработают!
Последний метод removeCandidate намекнул мне на то, что пора никапсулировать список кандидатов. Почему? Да потому что метод не работает ни с одним полем класса RecruitingDepartment. Еще бы он работает на уровне конкретного спискА а не на уровне спискОВ. А значит он должен быть перемещен со списком, с которым работает в отдельный класс.
Если хорошенько поискать, то в этот новый класс уйдет так же метод извлеченный из вот этого метода:
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... ничего не менялось ... @Override // а тут пришла команда оповестить всех кандидатов public void notice() { // он достает основной список и уведомляет всех старичков... for (Resume resume : candidates) { // ...обо всем, что накопилось за это время // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(newVacancies)); } // после он переносит новые вакансии в список историю vacancies.addAll(newVacancies); newVacancies.clear(); // далее он информирует всех новеньких... for (Resume resume : newCandidates) { // ... абсолютно всеми вакансиями // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(vacancies)); } // после чего переносит резюме новеньких кандидатов в основной список candidates.addAll(newCandidates); newCandidates.clear(); } }
Видишь два цикла? Они почти одинаковые, только итерируемся каждый раз по разному. Я давно это место приглянул, потому как в этом методе всякий раз внося новые изменения я вносил из жважды, по ожному в каждый цикл - а это симптом. Хотя нет это уже диагноз.
Заметь, так выглядит читабельнее
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // ... ничего не менялось ... @Override // а тут пришла команда оповестить всех кандидатов public void notice() { // он достает основной список и уведомляет всех старичков обо всем, // что накопилось за время с прошлого оповещения noticeAll(candidates, newVacancies); // после он переносит новые вакансии в список историю vacancies.addAll(newVacancies); newVacancies.clear(); // далее он информирует всех новеньких абсолютно всеми вакансиями noticeAll(newCandidates, vacancies); // после чего переносит резюме новеньких кандидатов в основной список candidates.addAll(newCandidates); newCandidates.clear(); } // метод нотификации всех кандидатов списком вакансий private void noticeAll(List<Resume> candidates, List<Vacancy> vacancies) { for (Resume resume : candidates) { // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(vacancies)); } } }
Всегда, когда код становится более OOP он становится более читабельным!
Если посмотреть на метод noticeAll, то он так же работает на уровне спискА а не на уровне спискОВ, где полагает быть методам рекрутера.
Закомитимся пока зеленая полоса.
А теперь выделим одно поле List<Resume> и два чуждых тут метода в новый класс и назовем его Candidates.
Осё
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Candidates { private List<Resume> list = new LinkedList<Resume>(); // так как добавляем мы кандидата по его резюме а удаляем по имени кандидата // то вот вам и метод void remove(Candidate candidate) { Collection<Resume> resumeForRemove = new LinkedList<Resume>(); for (Resume resume : list) { if (resume.getCandidate().equals(candidate)) { resumeForRemove.add(resume); } } list.removeAll(resumeForRemove); } // метод нотификации всех кандидатов списком вакансий void noticeAll(List<Vacancy> vacancies) { for (Resume resume : list) { // заметь, тут идет вызов makeNewList resume.getCandidate().haveANew(makeNewList(vacancies)); } } // а это новый метод копирования списка вакансий private Collection<Vacancy> makeNewList(List<Vacancy> vacancies) { return new LinkedList<Vacancy>(vacancies); } // метод добавления резюме кандидата в список void add(Resume resume) { list.add(resume); } // метод переноса всех резюме в другой список кандидатов void moveTo(Candidates candidates) { candidates.list.addAll(list); list.clear(); } }
Вау как много методов у него! А все методы были перемещены из реализации рекрутера, которая теперь выглядит проще
package myobserver; import java.util.LinkedList; import java.util.List; public class RecruitingDepartment implements Recruiter { // рекрутеру надо как-то отличать новых кандидатов от старых // тут будут хранитсья резюме новых кандидатов private Candidates newCandidates = new Candidates(); // а старая база резюме кандидатов - для тех, кто хоть раз получал уведомление private Candidates candidates = new Candidates(); // рекрутеру надо временно хранить свои новые вакансии private List<Vacancy> newVacancies = new LinkedList<Vacancy>(); // но так же ему надо складировать где-то вакансии, которые уже отправлялись private List<Vacancy> vacancies = new LinkedList<Vacancy>(); @Override // в момент, когда кандидат обращается к рекрутеру он передает свое резюме public void register(Resume resume) { // рекрутер сохраняет резюме кандидата в своем списке новых кандидатов newCandidates.add(resume); } @Override // рекрутеру пришла новая вакансия public void addNew(Vacancy vacancy) { // и он ее добавит в свой список новых вакансий newVacancies.add(vacancy); } @Override // метод отписки от рассылки public void remove(Candidate candidate) { // удяляем резюме кандидата из основного списка, если он там есть candidates.remove(candidate); // то же проделываем со списком новеньких кандидатов newCandidates.remove(candidate); } @Override // а тут пришла команда оповестить всех кандидатов public void notice() { // он достает список и уведомляет всех стареньких обо всем, // что накопилось за время с прошлого оповещения candidates.noticeAll(newVacancies); // после он переносит новые вакансии в список-историю vacancies.addAll(newVacancies); newVacancies.clear(); // потом он уведомляет всех новеньких всеми вакансиями newCandidates.noticeAll(vacancies); // после чего переносит резюме новеньких кандидатов в основной список newCandidates.moveTo(candidates); } }
Вот оно, прелесть ООП - всегда многовложенный сложный код можно сделать простым. Я не уверен нужны ли тут комментарии, но их я оставлю для тебя, читатель. В рабочей версии кода я никогда не пишу комментарии потому, что мой код хорошо за себя сам говорит.
Я по аналогии выделю класс вакансий, потому как чувствю, что со следующим моим изменением он наполнится методами, да и уже сейчас в него соберется достаточно методов.
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Vacancies { private List<Vacancy> list = new LinkedList<Vacancy>(); // метод добавления вакансии в список void add(Vacancy vacancy) { list.add(vacancy); } // а это новый метод копирования списка вакансий Collection<Vacancy> getCopy() { return new LinkedList<Vacancy>(list); } // метод перемещения вакансий в другой список void moveTo(Vacancies vacancies) { vacancies.list.addAll(list); list.clear(); } }
Измениллся так же список кандидатов
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Candidates { private List<Resume> list = new LinkedList<Resume>(); // так как добавляем мы кандидата по его резюме а удаляем по имени кандидата // то вот вам и метод void remove(Candidate candidate) { Collection<Resume> resumeForRemove = new LinkedList<Resume>(); for (Resume resume : list) { if (resume.getCandidate().equals(candidate)) { resumeForRemove.add(resume); } } list.removeAll(resumeForRemove); } // метод нотификации всех кандидатов списком вакансий void noticeAll(Vacancies vacancies) { for (Resume resume : list) { // заметь, тут список копируется resume.getCandidate().haveANew(vacancies.getCopy()); } } // метод добавления резюме кандидата в список void add(Resume resume) { list.add(resume); } // метод переноса всех резюме в другой список кандидатов void moveTo(Candidates candidates) { candidates.list.addAll(list); list.clear(); } }
Обрати внимание на строчку
resume.getCandidate().haveANew(vacancies.getCopy());
когда-то она коряво выглядела так
resume.getCandidate().haveANew(makeNewList(vacancies));
с некрасивым апендиксом
// а это новый метод копирования списка вакансий private Collection<Vacancy> makeNewList(List<Vacancy> vacancies) { return new LinkedList<Vacancy>(vacancies); }
посмотри как он гармонично выглядит в классе Vacancies
public class Vacancies { private List<Vacancy> list = new LinkedList<Vacancy>(); Collection<Vacancy> getCopy() { return new LinkedList<Vacancy>(list); } // ... другие методы }
Нравится? А?!
На еще! Вот как после этого упростится наша реализация рекрутера. Я уберу комментарии, чтобы было видно, насколько красива может быть объектная джава
package myobserver; public class RecruitingDepartment implements Recruiter { private Candidates newCandidates = new Candidates(); private Candidates candidates = new Candidates(); private Vacancies newVacancies = new Vacancies(); private Vacancies vacancies = new Vacancies(); @Override public void register(Resume resume) { newCandidates.add(resume); } @Override public void addNew(Vacancy vacancy) { newVacancies.add(vacancy); } @Override public void remove(Candidate candidate) { candidates.remove(candidate); newCandidates.remove(candidate); } @Override public void notice() { candidates.noticeAll(newVacancies); newVacancies.moveTo(vacancies); newCandidates.noticeAll(vacancies); newCandidates.moveTo(candidates); } }
Вот и вся логика нашего рекрутера. Рекрутер, вооружившись своими умными списками
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Vacancies { private List<Vacancy> list = new LinkedList<Vacancy>(); void add(Vacancy vacancy) { list.add(vacancy); } Collection<Vacancy> getCopy() { return new LinkedList<Vacancy>(list); } void moveTo(Vacancies vacancies) { vacancies.list.addAll(list); list.clear(); } }
и
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Candidates { private List<Resume> list = new LinkedList<Resume>(); void remove(Candidate candidate) { Collection<Resume> resumeForRemove = new LinkedList<Resume>(); for (Resume resume : list) { if (resume.getCandidate().equals(candidate)) { resumeForRemove.add(resume); } } list.removeAll(resumeForRemove); } void noticeAll(Vacancies vacancies) { for (Resume resume : list) { resume.getCandidate().haveANew(vacancies.getCopy()); } } void add(Resume resume) { list.add(resume); } void moveTo(Candidates candidates) { candidates.list.addAll(list); list.clear(); } }
Стал в два раза проще :)
Тесты зеленые - коммитимся.
Последнее, что осталось сделать - реализовать фильтрование: отправлять вакансии только тем кандидатам, которые потянут эту должность.
Новое требование - новый тест!
package myobserver; import static org.junit.Assert.*; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import org.junit.Test; public class RecrutingTest { // ... другие inner классы не менялись ... // вакансия, с заданным списком технологий class ConcreteVacancy implements Vacancy { private Set<String> technologies; // вакансия создается для конкретного списка технологий public ConcreteVacancy(String...technologies) { this.technologies = new HashSet<String>(Arrays.asList(technologies)); } @Override // который мы возвращаем при надобности public Set<String> getTechnologies() { return technologies; } @Override // только если весь список совпадает - тогда нас утраивает public boolean statisfy(Set<String> technologies) { return technologies.containsAll(technologies); } } // резюме кандидата, который что-то знает class CandidateResume extends FresherResume implements Resume { private HashSet<String> technologies; // сохраняем имя кандидата и список технологий ему известных public CandidateResume(Candidate candidate, String...technologies) { super(candidate); this.technologies = new HashSet<String>(Arrays.asList(technologies)); } @Override // в ответ на список технологий мы скажем что знаем public Set<String> getTechnologies() { return technologies; } } // ... тесты не менялись ... @Test // проверяем, что вакансии рассылаются только тем кандидатам, которые смогут реализовать вакансию public void testFilteringCandidatesByVacationTechnologies() { Recruiter recruiter = new RecruitingDepartment(); // новый javaEE кандидат VacanciesRecorder javaEECandidate = new VacanciesRecorder(); recruiter.register(new CandidateResume(javaEECandidate, "Java", "SQL", "Hibernate", "XML")); // новый PHP кандидат VacanciesRecorder phpCandidate = new VacanciesRecorder(); recruiter.register(new CandidateResume(phpCandidate, "PHP")); // появилась javaEE вакансия Vacancy javaEEVacancy = new ConcreteVacancy("Java", "SQL", "XML", "Hibernate"); recruiter.addNew(javaEEVacancy); // появилась PHP вакансия Vacancy pnpVacancy = new ConcreteVacancy("PHP"); recruiter.addNew(pnpVacancy); // еще одна фрешер-вакансия, на которую можно всем Vacancy fresherVacancy = new EmptyVacancy(); recruiter.addNew(fresherVacancy); // разослать приглашения recruiter.notice(); // джавист поулчил два предложения - джаваЕЕ- и фреш-вакансию assertSame(2, javaEECandidate.vacancies.size()); assertTrue(javaEECandidate.vacancies.contains(javaEEVacancy)); assertTrue(javaEECandidate.vacancies.contains(fresherVacancy)); // ПХПшник получил так же две - PHP- и фреш-вакансию assertSame(2, phpCandidate.vacancies.size()); assertTrue(phpCandidate.vacancies.contains(pnpVacancy)); assertTrue(phpCandidate.vacancies.contains(fresherVacancy)); } }
Тест не работает. Сделаем его рабочим!
Фильтрование добавил в метод информарования всех кандидатов
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; public class Candidates { // ... все остальное без изменений ... // метод нотификации всех кандидатов списком вакансий void noticeAll(Vacancies vacancies) { for (Resume resume : list) { // заметь, тут список фильтруется а потом копируется resume.getCandidate().haveANew(vacancies.filter(resume.getTechnologies()).getCopy()); } } }
Реализацию фильтра добавил в список вакансий
package myobserver; import java.util.Collection; import java.util.LinkedList; import java.util.List; import java.util.Set; public class Vacancies { // ... тут все без изменений ... // метод фильтрации списка вакансий по категориям Vacancies filter(Set<String> technologies) { Vacancies result = new Vacancies(); for (Vacancy vacancy : list) { if (vacancy.statisfy(technologies)) { result.add(vacancy); } } return result; } }
А вакансия умеет теперь отвечать на вопрос, удовлетворяет ли ей набор технологий или нет.
package myobserver; import java.util.Set; public interface Vacancy { // вакансия сожержит набор требуемых технологий // Set<String> getTechnologies(); // этого уже ненадо // вакансия умеет отвечать на вопрос, подходит ли ей список технологий или нет boolean statisfy(Set<String> technologies); }
Причем старого метода getTechnologies уже и не надо (слишком много он раскрывал).
И последнее - две тестовых реализации вакансии
package myobserver; import static org.junit.Assert.*; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Set; import org.junit.Test; public class RecrutingTest { // ... другие inner классы не менялись ... // вакансия, которая подойдет всем кандидатам - пустая class EmptyVacancy implements Vacancy { @Override // ей все подходит! public boolean statisfy(Set<String> technologies) { return true; } } // вакансия, с заданным списком технологий class ConcreteVacancy implements Vacancy { private Set<String> technologies; // вакансия создается для конкретного списка технологий public ConcreteVacancy(String...technologies) { this.technologies = new HashSet<String>(Arrays.asList(technologies)); } @Override // только если весь список совпадает - тогда нас утраивает public boolean statisfy(Set<String> technologies) { return this.technologies.containsAll(technologies); } } // ... тесты не менялись ... }
Вот и все...
Комментариев нет:
Отправить комментарий