Главная » Хабрахабр » Стартер для работы с Spring Cloud

Стартер для работы с Spring Cloud

Всем привет!

А также Spring Boot Admin и Zipkin для мониторинга. В этой статья я продемонстрирую основные компоненты для создания Reactive RESTful миксросервисов, используя Spring WebFlux, Spring Security, Spring Cloud Netflix Eureka (Service Discovery), Hystrix (Circuit Breaker), Ribbon (Client Side Load Balancer), External Configuration (через git repository), Spring Cloud Sleuth, Spring Cloud Gateway, Spring Boot Reactive MongoDB.

Данный обзор был сделан после изучения книг Spring Microservices in Action и Hands-On Spring 5 Security for Reactive Applications.

И реализацию аутентификации через JWT токен по мотивам книги Hands-On Spring 5 Security for Reactive Applications. В этой статье мы создадим элементарное приложение с тремя запросами: получить список игр, получить список игроков, создать игру из id игроков, запрос для проверки отката (Hystrix fallback) в случае долгого ожидания ответа.

Я не буду расписывать как создается каждое приложение в IDE, так как эта статья рассчитана на опытного пользователя.

Alt text

Модуль spring-servers можно смело копировать из проекта в проект. Проект состоит из двух модулей. Модуль tictactoe-services содержит модули и микросервисы нашего приложения. Там почти нет кода и конфигураций. Но на этапе разработки этих модулей, я считаю, что это самое оптимальное решение. Сразу замечу, что добавляя в сервисы модули auth-module и domain-module, я нарушаю один из принципов микросервисной архитектуры об автономности микросервисов.

Мне нравится когда вся конфигурация Gradle находится в одном файле, поэтому я сконфигурировал весь проект в одном build.gradle.

build.gradle

buildscript repositories { mavenCentral() maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") classpath("gradle.plugin.com.palantir.gradle.docker:gradle-docker:${gradleDockerVersion}") }
} allprojects { group = 'com.tictactoe' apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' apply plugin: 'com.palantir.docker' apply plugin: 'com.palantir.docker-run' apply plugin: 'com.palantir.docker-compose'
} docker.name = 'com.tictactoe'
bootJar.enabled = false sourceCompatibility = 11 repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" }
} subprojects { ext['springCloudVersion'] = 'Greenwich.M3' sourceSets.configureEach { sourceSet -> tasks.named(sourceSet.compileJavaTaskName, { options.annotationProcessorGeneratedSourcesDirectory = file("$buildDir/generated/sources/annotationProcessor/java/${sourceSet.name}") }) } repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" } } dependencyManagement { imports { mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } dependencies { compile fileTree(include: ['*.jar'], dir: 'libs') compileOnly('org.projectlombok:lombok') annotationProcessor('org.projectlombok:lombok') }
} project(':spring-servers') { bootJar.enabled = false task cleanAll { dependsOn subprojects*.tasks*.findByName('clean') } task buildAll { dependsOn subprojects*.tasks*.findByName('build') } dockerCompose { template 'docker-compose.spring-servers.template.yml' dockerComposeFile 'docker-compose.spring-servers.yml' }
} project(':tictactoe-services') { bootJar.enabled = false task cleanAll { dependsOn subprojects*.tasks*.findByName('clean') } task buildAll { dependsOn subprojects*.tasks*.findByName('build') }
} // Tictactoe Modules
project(':tictactoe-services:domain-module') { bootJar.enabled = false jar { enabled = true group 'com.tictactoe' baseName = 'domain-module' version = '1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-validation') implementation('com.fasterxml.jackson.core:jackson-annotations:2.9.3') implementation 'com.intellij:annotations:+@jar' compileOnly('org.projectlombok:lombok') testCompile group: 'junit', name: 'junit', version: '4.12' }
} project(':tictactoe-services:auth-module') { bootJar.enabled = false jar { enabled = true baseName = 'auth-module' version = '1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.security:spring-security-oauth2-core') implementation('org.springframework.security:spring-security-oauth2-jose') implementation 'com.intellij:annotations:+@jar' testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('io.projectreactor:reactor-test') testImplementation('org.springframework.security:spring-security-test') }
} project(':tictactoe-services:user-service') { bootJar { launchScript() baseName = 'user-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') }
} project(':tictactoe-services:game-service') { bootJar { launchScript() baseName = 'game-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') }
} project(':tictactoe-services:webapi-service') { bootJar { launchScript() baseName = 'webapi-service' version = '0.1.0' } dependencies { implementation project(':tictactoe-services:domain-module') implementation project(':tictactoe-services:auth-module') }
} // Spring Servers
project(':spring-servers:discovery-server') { bootJar { launchScript() baseName = 'discovery-server' version = '0.1.0' } dependencies { implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server') implementation('org.springframework.boot:spring-boot-starter-security') compile('javax.xml.bind:jaxb-api:2.3.0') compile('javax.activation:activation:1.1') compile('org.glassfish.jaxb:jaxb-runtime:2.3.0') testImplementation('org.springframework.boot:spring-boot-starter-test') }
} project(':spring-servers:config-server') { bootJar { launchScript() baseName = 'config-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.cloud:spring-cloud-config-server') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') }
} project(':spring-servers:gateway-server') { bootJar { launchScript() baseName = 'gateway-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-actuator') implementation('org.springframework.cloud:spring-cloud-starter-gateway') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') }
} project(':spring-servers:admin-server') { ext['springBootAdminVersion'] = '2.1.1' bootJar { launchScript() baseName = 'admin-server' version = '0.1.0' } dependencies { implementation('org.springframework.boot:spring-boot-starter-web') implementation('org.springframework.boot:spring-boot-starter-security') implementation('de.codecentric:spring-boot-admin-starter-server') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('org.springframework.security:spring-security-test') } dependencyManagement { imports { mavenBom "de.codecentric:spring-boot-admin-dependencies:${springBootAdminVersion}" } }
} subprojects { subproject -> if (file("${subproject.projectDir}/docker/Dockerfile").exists()) { docker { // workingbit - replace with your dockerhub's username name "workingbit/${subproject.group}.${subproject.bootJar.baseName}" tags 'latest' dockerfile file("${subproject.projectDir}/docker/Dockerfile") files tasks.bootJar.archivePath, 'docker/run.sh' buildArgs "JAR_FILE": "${subproject.bootJar.baseName}-${subproject.bootJar.version}.jar", "RUN_SH": "run.sh" } } else { docker.name = 'noop' } if (subproject.name.endsWith('service')) { dependencies { implementation('org.springframework.boot:spring-boot-starter-actuator') implementation('org.springframework.boot:spring-boot-starter-webflux') implementation('org.springframework.boot:spring-boot-starter-data-mongodb-reactive') implementation('org.springframework.boot:spring-boot-starter-security') implementation('org.springframework.security:spring-security-oauth2-core') implementation('org.springframework.security:spring-security-oauth2-jose') implementation('org.springframework.cloud:spring-cloud-starter-config') implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-client') implementation('org.springframework.cloud:spring-cloud-starter-netflix-hystrix') implementation('org.springframework.cloud:spring-cloud-starter-netflix-ribbon') implementation('org.springframework.cloud:spring-cloud-starter-sleuth') implementation('org.springframework.cloud:spring-cloud-starter-zipkin') implementation('org.springframework.security:spring-security-rsa') implementation('com.intellij:annotations:+@jar') implementation('org.apache.commons:commons-lang3:3.8.1') runtimeOnly('org.springframework.boot:spring-boot-devtools') testImplementation('org.springframework.boot:spring-boot-starter-test') testImplementation('de.flapdoodle.embed:de.flapdoodle.embed.mongo') testImplementation('io.projectreactor:reactor-test') } }
}

НО, это снова нарушает принцип автономности микросервисов. Использование общего конфигурационного файла позволяет вынести общие для микросервисов зависимости, в данном случае сервисы с именем оканчивающимся на “service”, в одно место. Я добавил задачи плагина gradle.plugin.com.palantir.gradle.docker:gradle-docker для работы с Docker. Кроме общих зависимостей в субпроекты можно добавлять задачи.

Описание пакета auth этого модуля можно найти в книге по реактивной аутентификации, которую я указал выше. Теперь, рассмотрим модуль аутентификации по JWT.

Alt text

А, на пакете config остановимся подробней.

@Data
@Component
@ConfigurationProperties("appclients")
public class ApplicationClientsProperties { private List<ApplicationClient> clients = new ArrayList<>(); @Data public static class ApplicationClient { private String username; private String password; private String[] roles; } }

Этот класс содержит “сложные” свойства для конфигурации inMemory базы данных.

@Data
@Configuration
@PropertySource("classpath:moduleConfig.yml")
public class AuthModuleConfig { @Value("${tokenExpirationMinutes:60}") private Integer tokenExpirationMinutes; @Value("${tokenIssuer:workingbit-example.com}") private String tokenIssuer; @Value("${tokenSecret:secret}") // length minimum 256 bites private String tokenSecret;
}

В моей конфигурации токен протухает через 10 часов. В файле ресурсов необходимо указать данные переменные.

public class MicroserviceServiceJwtAuthWebFilter extends JwtAuthWebFilter { private final String[] matchersStrings; public MicroserviceServiceJwtAuthWebFilter(JwtService jwtService, String[] matchersStrings) { super(jwtService); this.matchersStrings = matchersStrings; } @Override protected ServerWebExchangeMatcher getAuthMatcher() { List<ServerWebExchangeMatcher> matchers = Arrays.stream(this.matchersStrings) .map(PathPatternParserServerWebExchangeMatcher::new) .collect(Collectors.toList()); return ServerWebExchangeMatchers.matchers(new OrServerWebExchangeMatcher(matchers)); }
}

В этот фильтр, при конструкции, передается сервис для работы с JWT и список путей, которые будет обрабатывать этот фильтр.

@ConditionalOnProperty(value = "microservice", havingValue = "true")
@EnableReactiveMethodSecurity
@PropertySource(value = "classpath:/application.properties")
public class MicroserviceSpringSecurityWebFluxConfig { @Value("${whiteListedAuthUrls}") private String[] whiteListedAuthUrls; @Value("${jwtTokenMatchUrls}") private String[] jwtTokenMatchUrls; /** * Bean which configures whiteListed and JWT filter urls * Also it configures authentication for Actuator. Actuator takes configured AuthenticationManager automatically * which uses MapReactiveUserDetailsService to configure inMemory users */ @Bean public SecurityWebFilterChain springSecurityFilterChain( ServerHttpSecurity http, JwtService jwtService ) { MicroserviceServiceJwtAuthWebFilter userServiceJwtAuthWebFilter = new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls); http.csrf().disable(); http .authorizeExchange() .pathMatchers(whiteListedAuthUrls) .permitAll() .and() .authorizeExchange() .pathMatchers("/actuator/**").hasRole("SYSTEM") .and() .httpBasic() .and() .addFilterAt(userServiceJwtAuthWebFilter, SecurityWebFiltersOrder.AUTHENTICATION); return http.build(); } }

Здесь есть сразу три интересные аннотации.

@ConditionalOnProperty(value = "microservice", havingValue = "true")

Это необходимо для того, чтобы в некоторых модулях общую проверку по токену. Аннотация, которая подключает этот модуль в зависимости от переменной microservice в файле конфигураций, который указан в аннотации. В данном приложении это сервис webapi-service, который имеет свою реализацию бина SecurityWebFilterChain.

@PropertySource(value = "classpath:/application.properties")

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

@Value("${whiteListedAuthUrls}")
private String[] whiteListedAuthUrls;
@Value("${jwtTokenMatchUrls}")
private String[] jwtTokenMatchUrls;

Берут свои значения из конфигурации микросервиса.

И, аннотация, которая позволяет навешивать аннотации безопасности @PreAuthorize(“hasRole(‘MY_ROLE’)”)

@EnableReactiveMethodSecurity

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

В этой конфигурации создаются бины MapReactiveUserDetailsService для конфигурации актуатора и других системных пользователей в памяти.

@Bean
@Primary
public MapReactiveUserDetailsService userDetailsRepositoryInMemory() { List<UserDetails> users = applicationClients.getClients() .stream() .map(applicationClient -> User.builder() .username(applicationClient.getUsername()) .password(passwordEncoder().encode(applicationClient.getPassword())) .roles(applicationClient.getRoles()).build()) .collect(toList()); return new MapReactiveUserDetailsService(users);
}

Бин ReactiveUserDetailsService который необходим для сшивки репозитория нашего пользователя с Spring Security.

@Bean
public ReactiveUserDetailsService userDetailsRepository(UserRepository users) { return (email) -> users.findByEmail(email).cast(UserDetails.class);
}

Бин создания WebClient — клиента для выполнения реактивных запросов.

@Bean
public WebClient loadBalancedWebClientBuilder(JwtService jwtService) { return WebClient.builder() .filter(lbFunction) .filter(authorizationFilter(jwtService)) .build();
} private ExchangeFilterFunction authorizationFilter(JwtService jwtService) { return ExchangeFilterFunction .ofRequestProcessor(clientRequest -> ReactiveSecurityContextHolder.getContext() .map(securityContext -> ClientRequest.from(clientRequest) .header(HttpHeaders.AUTHORIZATION, jwtService.getHttpAuthHeaderValue(securityContext.getAuthentication())) .build()));
}

LoadBalancer и фильтр который берет из контекста ReactiveSecurityContext инстанс Authentication и из него создает токен для того, чтобы его аутентифицировал фильтр целевого сервер и соответственно авторизовал. Во время создания добавляются два фильтра.

И для удобства работы с типом MongoDB ObjectId и датами, я добавил бин создания objectMapper’а:

@Bean
@Primary
ObjectMapper objectMapper() { Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); builder.serializerByType(ObjectId.class, new ToStringSerializer()); builder.deserializerByType(ObjectId.class, new JsonDeserializer() { @Override public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { Map oid = p.readValueAs(Map.class); return new ObjectId( (Integer) oid.get("timestamp"), (Integer) oid.get("machineIdentifier"), ((Integer) oid.get("processIdentifier")).shortValue(), (Integer) oid.get("counter")); } }); builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return builder.build();
}

Микросервис game-service имеет следующую структуру:

Alt text

Как можно увидеть в нем только один файл конфигурации ApplicationConfig

@Data
@Configuration
@EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository")
@Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class})
public class ApplicationConfig { @Value("${userserviceUrl}") private String userServiceUrl; }

В нем содержится переменная с адресом сервиса user-service и есть две интересные аннотации:

@EnableReactiveMongoRepositories("com.tictactoe.gameservice.repository")

Эта аннотация необходима для того, чтобы указать конфигуратору репозиторий MongoDB.

@Import({ApplicationClientsProperties.class, SpringWebFluxConfig.class, MicroserviceSpringSecurityWebFluxConfig.class})

Эта аннотация импортирует конфигурации из модуля auth-module.

В этом сервисе есть только следующий интересный код:

@HystrixCommand
public Flux<Game> getAllGames() { return gameRepository.findAll();
} @HystrixCommand(fallbackMethod = "buildFallbackAllGames", threadPoolKey = "licenseByOrgThreadPool", threadPoolProperties = {@HystrixProperty(name = "coreSize", value = "30"), @HystrixProperty(name = "maxQueueSize", value = "10")}, commandProperties = { @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"), @HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "75"), @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "7000"), @HystrixProperty(name = "metrics.rollingStats.timeInMilliseconds", value = "15000"), @HystrixProperty(name = "metrics.rollingStats.numBuckets", value = "5")}
)
public Flux<Game> getAllGamesLong() {
// logger.debug("LicenseService.getLicensesByOrg Correlation id: {}", UserContextHolder.getContext().getCorrelationId()); randomlyRunLong(); return gameRepository.findAll();
}

Этот метод случайно выбрасывает исключение и Hystrix в соответствии с аннотацией возвращает результат работы следующего метода:

private Flux<Game> buildFallbackAllGames() { User fakeUserBlack = new User("fakeUserBlack", "password", Collections.emptyList()); User fakeUserWhite = new User("fakeUserBlack", "password", Collections.emptyList()); Game game = new Game(fakeUserBlack, fakeUserWhite); List<Game> games = List.of(game); return Flux.fromIterable(games);
}

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

Цель этого сервиса получить выборку с других сервисов и сформировать на ее основе ответ пользователю. Это своеобразный middleware между Gateway и внутренними микросервисами, которые не видны снаружи.

Alt text

Начнем рассмотрение с конфигурации.

@Configuration
@EnableReactiveMethodSecurity
public class SpringSecurityWebFluxConfig { private static final String AUTH_TOKEN_PATH = "/auth/token"; @Value("${whiteListedAuthUrls}") private String[] whiteListedAuthUrls; @Value("${jwtTokenMatchUrls}") private String[] jwtTokenMatchUrls; @Bean @Primary public SecurityWebFilterChain systemSecurityFilterChain( ServerHttpSecurity http, JwtService jwtService, @Qualifier("userDetailsRepository") ReactiveUserDetailsService userDetailsService ) {

Здесь мы создаем менеджер аутентификации с сервисов userDetailsService, который мы определили ранее в модуле auth-module.

UserDetailsRepositoryReactiveAuthenticationManager authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);

И создаем фильтр с этим менеджером, а также добавляем конвертер инстанса Authentication для того, чтобы получить данные пользователя закодированные в x-www-form-urlencoded.

AuthenticationWebFilter tokenWebFilter = new AuthenticationWebFilter(authenticationManager); tokenWebFilter.setServerAuthenticationConverter(exchange -> Mono.justOrEmpty(exchange) .filter(ex -> AUTH_TOKEN_PATH.equalsIgnoreCase(ex.getRequest().getPath().value())) .flatMap(ServerWebExchange::getFormData) .filter(formData -> !formData.isEmpty()) .map((formData) -> { String email = formData.getFirst("email"); String password = formData.getFirst("password"); return new UsernamePasswordAuthenticationToken(email, password); }) );

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

tokenWebFilter.setAuthenticationSuccessHandler(new JwtAuthSuccessHandler(jwtService)); MicroserviceServiceJwtAuthWebFilter webApiJwtServiceWebFilter = new MicroserviceServiceJwtAuthWebFilter(jwtService, jwtTokenMatchUrls); http.csrf().disable(); http .authorizeExchange()

Как я писал ранее адреса, которые будут обрабатываться JWT фильтром так же надо открывать Разрешаем адреса из белого списка.

.pathMatchers(whiteListedAuthUrls) .permitAll() .and() .authorizeExchange()

Защищаем базовой аутентификацией актуатор и некоторые адреса

.pathMatchers("/actuator/**").hasRole("SYSTEM") .pathMatchers(HttpMethod.GET, "/url-protected/**").hasRole("GUEST") .pathMatchers(HttpMethod.POST, "/url-protected/**").hasRole("USER") .and() .httpBasic() .and() .authorizeExchange()

Делаем обязательной аутентификацию для доступа к токену

.pathMatchers(AUTH_TOKEN_PATH).authenticated() .and()

Для аутентификации и проверки JWT токена. Добавляем фильтры.

.addFilterAt(webApiJwtServiceWebFilter, SecurityWebFiltersOrder.AUTHENTICATION) .addFilterAt(tokenWebFilter, SecurityWebFiltersOrder.AUTHENTICATION); return http.build();
}

И как писал выше, этот сервис отключает общую для остальных сервисов проверку JWT токена, указав значение переменной micoservice=false в файле application.properites.

Описывать этот контроллер я не буду, так как он сугубо специфичен.

Этот сервис вызывается в контроллере WebApiMethodProtectedController.java и имеет интересную аннотацию:

@PreAuthorize("hasRole('GUEST')")
public Flux<User> getAllUsers() {
}

Эта аннотация разрешает доступ к методы только авторизованным пользователям с ролью гость.

Создайте окружение:

Alt text

Получите токен

Alt text

Обновите в окружении переменную TOKEN полученным токеном.

Зарегистрируйте нового пользователя

Alt text

Он истекает через 10 часов. После регистрации вы получите токен пользователя. Для этого снова запросите гостевой токен, обновите окружение и выполните запрос Когда он истечет нужно получить новый.

Alt text

А так же протестировать Hystrix, посмотреть конфиги сервисов и зашифровать переменные для git репозитория. Далее, можно получить список пользователей, игр или создать новую игру.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Изучаем Go: подборка видеозаписей докладов

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

[Перевод] Особенности использования типа данных Symbol в JavaScript

Символьные примитивы — это одно из новшеств стандарта ES6, которое принесло в JavaScript некоторые ценные возможности. Символы, представленные типом данных Symbol, особенно полезны при использовании их в качестве идентификаторов свойств объектов. В связи с таким сценарием их применения напрашивается вопрос ...