предисловие
Солнечным днем менеджер проекта похлопал меня по плечу и сказал: "Сяо Чжоу, есть небольшой запрос, который нуждается в вашей поддержке. Он будет запущен в конце месяца. Сначала вы должны изучить спрос. Когда я уже собирался возвращаться, как обнаружил, что менеджер проекта разговаривает по телефону, и с улыбкой убежал, сказав: «Здравствуйте...»
Скромная разработка наконец-то сформировала спрос — пристыковать ElasticSearch, написать API для бэкенда, но его запустят меньше чем через две недели (продукт дня…). Наконец, я решил использовать SpringBoot Data ElasticSearch и сделать на его основе простую инкапсуляцию, основанную на
ElasticsearchRestTemplate
Внедрите API для предоставления услуг серверной части. Поддерживается CURD.Поскольку запрос должен поддерживать SQL-запрос, дополнительно ссылаются на пакет bboss, чтобы помочь в анализе объекта.
дизайн
Разделен на два модуля: API-Module и Service-Module.
- API-Module
-
- xxxEntity.java: определяет сущность, параметры индекса и параметры знакомства полей документа es.
-
- EsQueryAnnotation: аннотация, определяющая данные, используемые для запроса Es.
-
- xxxReq.java: сущность запроса Es, указанная выше EsQueryAnnotation предназначена для него.
-
- xxxResp.java: объект, который получает данные Es
-
- xxxFacade.java: открыт интерфейс Dubbo
- Service-Module
-
- EsQueryParse.java: Анализ EsQueryAnnotation и сборка в общий запрос.
-
- xxxService/xxxxServiceImpl.java: Интерфейс службы и реализация
окрестности
- ElasticSearch : 7.7.1
- SpringBoot: 2.3.1.RELEASE
- JDK1.8
Временная диаграмма
Подобно обычному CURD, будь то операция индекса или данных es, ее необходимо выполнять через сущность.
пример кода
pom.xml
<properties>
<!--依赖版本-->
<spring-boot.version>2.3.1.RELEASE</spring-boot.version>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
<scope>compile</scope>
</dependency>
<!-- Es -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>com.bbossgroups.plugins</groupId>
<artifactId>bboss-elasticsearch-rest-jdbc</artifactId>
<version>6.1.8</version>
</dependency>
</dependencies>
Api-Module
аннотация запроса
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EsEquals {
/**
* filed name
*/
String name() default "";
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EsIn {
/**
* filed name
*/
String name() default "";
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EsLike {
/**
* filed name
*/
String name() default "";
boolean leftLike() default false;
boolean rightLike() default false;
}
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EsRange {
/**
* filed name
*/
String name() default "";
/**
* >
*/
boolean lt() default false;
/**
* <
*/
boolean gt() default false;
/**
* 包含上界
*/
boolean includeUpper() default false;
/**
* 包含下界
*/
boolean includeLower() default false;
}
Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Document(indexName = "ems", shards = 1, replicas = 1)
public class EsDocBeanEntity extends EsBaseEntity {
@Id
private String id;
@Field(type = FieldType.Keyword)
private String firstCode;
@Field(type = FieldType.Keyword)
private String secordCode;
@Field(type = FieldType.Text)
private String content;
@Field(type = FieldType.Integer)
private Integer type;
}
EsResult
@Getter
@Setter
public class EsResult<T> extends EsBaseResp {
private T data;
private Long pageCount;
private Long recordCount;
public void setPageCount(Long recordCount, Long size) {
this.pageCount = recordCount / size;
}
public static <T> EsResult<T> of(T t) {
return EsResult.of(t, null, null, null);
}
public static <T> EsResult<T> of(T t, Long recordCount) {
return EsResult.of(t, recordCount, null, null);
}
public static <T> EsResult<T> of(T t, Long recordCount, Long pageCount) {
return EsResult.of(t, pageCount, recordCount, null);
}
public static <T> EsResult<T> of(T t, Long recordCount, Long pageCount, Long pageSize) {
EsResult<T> esResult = new EsResult<>();
esResult.setData(t);
if (recordCount != null) {
esResult.setRecordCount(recordCount);
}
if (pageCount != null) {
esResult.setPageCount(pageCount);
}
if (pageSize != null) {
esResult.setPageCount(recordCount, pageSize);
}
return esResult;
}
}
EsPageable
@Getter
@Setter
public class EsPageable {
private Integer page = 1;
private Integer size = 15;
private String orderBy = "";
private String order = "";
public enum OrderEnum {
/**
* 正序
*/
ASC("ASC"),
/**
* 倒叙
*/
DESC("DESC");
/**
* 值
*/
private String value;
OrderEnum(String value) {
this.value = value;
}
}
public static Sort getQuerySort(EsPageable pageable) {
Sort sort = null;
if (StringUtils.isNotBlank(pageable.getOrderBy()) && pageable.getOrder() != null) {
if (StringUtils.equalsIgnoreCase(pageable.getOrder(), EsBaseReq.OrderEnum.ASC.name())) {
sort = Sort.by(Sort.Order.asc(pageable.getOrderBy()));
} else {
sort = Sort.by(Sort.Order.desc(pageable.getOrderBy()));
}
}
return sort == null ? Sort.unsorted() : sort;
}
public static Pageable getQueryPageable(EsPageable pageable) {
int page = pageable.getPage() - 1;
Integer size = pageable.getSize();
return PageRequest.of(page, size);
}
}
Request
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
@Document(indexName = "ems")
public class EsDocBeanReq extends EsBaseReq {
@EsEquals(name = "_id")
private String id;
@EsEquals
private String firstCode;
@EsEquals
private String secordCode;
@EsLike
private String content;
@EsIn(name = "type")
private List<Integer> typeList;
@EsRange(lt = true, name = "type")
private Integer typeRange;
}
Respone
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class EsDocBeanResp extends EsBaseResp {
private String id;
private String firstCode;
private String secordCode;
private String content;
private Integer type;
}
Service-Module
EsQueryParse
@Slf4j
public class EsQueryParse {
private EsQueryParse() {
}
public static <T> Query convert2Query(T t) {
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
BoolQueryBuilder boolQueryBuilder = getBoolQueryBuilder(t);
queryBuilder.withQuery(boolQueryBuilder);
return queryBuilder.build();
}
private static <T> BoolQueryBuilder getBoolQueryBuilder(T t) {
return getBoolQueryBuilder(t, null);
}
private static <T> BoolQueryBuilder getBoolQueryBuilder(T t, String nestedPath) {
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
Class<?> clazz = t.getClass();
Field[] fields = clazz.getDeclaredFields();
nestedPath = nestedPath == null ? "" : nestedPath;
try {
for (Field field : fields) {
Object value = ClassUtils.getPublicMethod(clazz, "get" + captureName(field.getName())).invoke(t);
if (value == null) {
continue;
}
if (field.isAnnotationPresent(EsLike.class)) {
WildcardQueryBuilder query = getLikeQuery(field, value, nestedPath);
boolQueryBuilder.must(query);
}
if (field.isAnnotationPresent(EsEquals.class)) {
MatchQueryBuilder query = getEqualsQuery(field, value, nestedPath);
boolQueryBuilder.must(query);
}
if (field.isAnnotationPresent(EsRange.class)) {
RangeQueryBuilder query = getRangeQuery(field, value, nestedPath);
boolQueryBuilder.must(query);
}
if (field.isAnnotationPresent(EsIn.class)) {
TermsQueryBuilder query = getInQuery(field, (List<?>) value, nestedPath);
boolQueryBuilder.must(query);
}
if (field.isAnnotationPresent(EsNotNull.class)) {
ExistsQueryBuilder query = getNotNullQuery(field, nestedPath);
boolQueryBuilder.must(query);
}
if (field.isAnnotationPresent(EsNotNullFields.class)) {
List<ExistsQueryBuilder> query = getNotNullQuery((List<String>) value, nestedPath);
Optional.ofNullable(query).orElse(new ArrayList<>())
.forEach(boolQueryBuilder::must);
}
if (field.isAnnotationPresent(EsNested.class)) {
NestedQueryBuilder query = getNestedQuery(field, value);
boolQueryBuilder.must(query);
}
}
} catch (Exception e) {
log.info("ES查询解析异常:{}", e.getMessage());
}
return boolQueryBuilder;
}
private static TermsQueryBuilder getInQuery(Field field, List<?> value, String nestedPath) {
EsIn esIn = field.getAnnotation(EsIn.class);
String filedName = getFiledName(field, esIn.name(), nestedPath);
return QueryBuilders.termsQuery(filedName, value);
}
private static RangeQueryBuilder getRangeQuery(Field field, Object value, String nestedPath) {
EsRange esRange = field.getAnnotation(EsRange.class);
String filedName = getFiledName(field, esRange.name(), nestedPath);
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery(filedName)
.includeLower(esRange.includeLower())
.includeUpper(esRange.includeUpper());
if (esRange.lt()) {
rangeQueryBuilder.gt(value);
}
if (esRange.gt()) {
rangeQueryBuilder.gt(value);
}
return rangeQueryBuilder;
}
private static MatchQueryBuilder getEqualsQuery(Field field, Object value, String nestedPath) {
EsEquals esEquals = field.getAnnotation(EsEquals.class);
String filedName = getFiledName(field, esEquals.name(), nestedPath);
return QueryBuilders.matchQuery(filedName, value);
}
private static WildcardQueryBuilder getLikeQuery(Field field, Object value, String nestedPath) {
String likeValue = (String) value;
EsLike esLike = field.getAnnotation(EsLike.class);
String filedName = getFiledName(field, esLike.name(), nestedPath);
if (esLike.leftLike()) {
likeValue = "*" + likeValue;
}
if (esLike.rightLike()) {
likeValue = likeValue + "*";
}
return QueryBuilders.wildcardQuery(filedName, likeValue);
}
private static ExistsQueryBuilder getNotNullQuery(Field field, String nestedPath) {
EsNotNull esNotNull = field.getAnnotation(EsNotNull.class);
String filedName = getFiledName(field, esNotNull.name(), nestedPath);
return QueryBuilders.existsQuery(filedName);
}
private static List<ExistsQueryBuilder> getNotNullQuery(List<String> value, String nestedPath) {
if (CollectionUtils.isEmpty(value)) {
return new ArrayList<>();
}
return value.stream()
.map(item-> getFiledName(item, nestedPath))
.map(QueryBuilders::existsQuery)
.collect(Collectors.toList());
}
private static NestedQueryBuilder getNestedQuery(Field field, Object object) {
EsNested esNested = field.getAnnotation(EsNested.class);
String nestedPath = getFiledName(field, esNested.name(), "");
QueryBuilder boolQueryBuilder = getBoolQueryBuilder(object, nestedPath);
return QueryBuilders.nestedQuery(nestedPath, boolQueryBuilder, ScoreMode.None);
}
private static String getFiledName(Field field, String name, String nestedPath) {
String fileName = name;
if (field != null) {
fileName = StringUtils.isBlank(name) ? field.getName() : name;
}
if (StringUtils.isBlank(nestedPath)) {
return fileName;
}
return nestedPath + "." + fileName;
}
private static String getFiledName(String name, String nestedPath) {
return getFiledName(null, name, nestedPath);
}
public static String captureName(String name) {
char[] cs = name.toCharArray();
cs[0] -= 32;
return String.valueOf(cs);
}
}
service
/**
* 对ElasticSearch 数据操作
*
* @author: zhoukun@hztianque.com
*/
@Validated
public interface EsDataService {
/**
* 添加一条数据
*
* @param t obj
*/
<T> T save(@NotNull T t);
/**
* 批量添加数据
*
* @param tList
*/
<T> boolean batchSave(@NotEmpty List<T> tList);
/**
* 单个更新
*
* @param t
* @param <T>
* @return
*/
<T> boolean update(@NotNull T t);
/**
* 批量更新
*
* @param tList
* @param <T>
* @return
*/
<T> boolean batchUpdate(@NotEmpty List<T> tList);
/**
* 删除
*
* @param clazz
* @param id
* @param <T>
* @return
*/
<T> boolean delete(@NotNull Class<T> clazz, @NotBlank String id);
/**
* 批量删除
*
* @param clazz
* @param idList
* @param <T>
* @return
*/
<T> boolean batchDelete(@NotNull Class<T> clazz, @NotEmpty List<String> idList);
/**
* 根据id查询一个对象
*
* @param clazz
* @param id
* @return
*/
<T> T findById(@NotNull Class<T> clazz, @NotBlank String id);
/**
* 根据查询条件查询对象
*
* @param clazz
* @param query
* @return
*/
<T> T findOne(@NotNull Class<T> clazz, @NotNull Query query);
/**
* 根据查询条件查询对象
*
* @param clazz
* @param req
* @return
*/
<T> T findOne(@NotNull Class<T> clazz, @NotNull EsBaseReq req);
/**
* 查询某个对象的所有数据,慎用
*
* @param clazz
* @return
*/
<T> EsResult<List<T>> findAll(@NotNull Class<T> clazz);
/**
* 查询某个对象的所有数据,提供分页排序参数
*
* @param clazz
* @param pageable
* @return
*/
<T> EsResult<List<T>> findAll(@NotNull Class<T> clazz, @NotNull EsPageable pageable);
/**
* 根据条件查询数据
*
* @param t
* @param query
* @return
*/
<T> EsResult<List<T>> search(@NotNull Class<T> t, @NotNull Query query);
/**
* 根据es查询条件查询
*
* @param t
* @param request
* @return
*/
<T> EsResult<List<T>> search(@NotNull Class<T> t, @NotNull EsBaseReq request);
/**
* 手写dsl查询条件查询数据
*
* @param t
* @param dsl
* @return
*/
<T> EsResult<List<T>> dslSearch(@NotNull Class<T> t, @NotBlank(message = "dsl语句不能为空") String dsl);
/**
* 使用SQL 条件查询数据
*
* @param t
* @param sql
* @return
*/
<T> List<T> sqlSearch(@NotNull Class<T> t, @NotBlank(message = "sql语句不能为空") String sql);
/**
* 使用SQL查询单个对象
*
* @param t
* @param sql
* @return
*/
<T> T sqlFindOne(@NotNull Class<T> t, @NotBlank(message = "sql语句不能为空") String sql);
}
/**
* 对ElasticSearch 索引操作
*
* @date: 2020/8/10 00:00
*/
public interface EsIndexService {
/**
* 创建索引
*
* @param t
* @return
*/
<T> boolean createIndex(Class<T> t);
/**
* 删除索引
*
* @param t
* @return
*/
<T> boolean deleteIndex(Class<T> t);
/**
* 重建索引,数据会被删除
*
* @param t
* @return
*/
<T> boolean reCreate(Class<T> t);
/**
* 判断索引是否存在
*
* @param t
* @return
*/
<T> boolean indexExists(Class<T> t);
}
serviceImpl
/**
* @date: 2020/8/5 19:22
*/
@Service
public class EsDataServiceImpl implements EsDataService {
@Resource
private RestClient restClient;
@Resource
private ElasticsearchRestTemplate elasticsearchTemplate;
private static Gson gson = new Gson();
@Override
public <T> T save(T t) {
return elasticsearchTemplate.save(t);
}
@Override
public <T> boolean batchSave(List<T> tList) {
elasticsearchTemplate.save(tList);
return true;
}
@Override
public <T> boolean update(T t) {
return true;
}
@Override
public <T> boolean batchUpdate(List<T> tList) {
return true;
}
@Override
public <T> boolean delete(Class<T> clazz, String id) {
Annotation annotation = AnnotationUtils.getAnnotation(clazz, Document.class);
Document document = (Document) annotation;
IndexCoordinates index = IndexCoordinates.of(document.indexName());
elasticsearchTemplate.delete(id, index);
return true;
}
@Override
public <T> boolean batchDelete(Class<T> clazz, List<String> idList) {
Annotation annotation = AnnotationUtils.getAnnotation(clazz, Document.class);
Document document = (Document) annotation;
IndexCoordinates index = IndexCoordinates.of(document.indexName());
idList.forEach(id -> elasticsearchTemplate.delete(id, index));
return true;
}
@Override
public <T> T findById(Class<T> clazz, String id) {
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
queryBuilder.withQuery(QueryBuilders.matchQuery("_id", id));
SearchHit<T> searchHit = elasticsearchTemplate.searchOne(queryBuilder.build(), clazz);
return searchHit == null ? null : searchHit.getContent();
}
@Override
public <T> T findOne(Class<T> clazz, Query query) {
SearchHit<T> searchHit = elasticsearchTemplate.searchOne(query, clazz);
return searchHit == null ? null : searchHit.getContent();
}
@Override
public <T> T findOne(Class<T> clazz, EsBaseReq esBaseReq) {
Query query = EsQueryParse.convert2Query(esBaseReq);
SearchHit<T> searchHit = elasticsearchTemplate.searchOne(query, clazz);
return searchHit != null ? searchHit.getContent() : null;
}
@Override
public <T> EsResult<List<T>> findAll(Class<T> clazz) {
SearchHits<T> searchHits = elasticsearchTemplate.search(Query.findAll(), clazz);
List<T> data = searchHits.get().map(SearchHit::getContent).collect(Collectors.toList());
return EsResult.of(data, searchHits.getTotalHits());
}
@Override
public <T> EsResult<List<T>> findAll(Class<T> clazz, EsPageable pageable) {
Query query = new StringQuery(QueryBuilders.boolQuery().toString());
query.setPageable(EsPageable.getQueryPageable(pageable));
query.addSort(EsPageable.getQuerySort(pageable));
SearchHits<T> search = elasticsearchTemplate.search(query, clazz);
List<T> data = search.get().map(SearchHit::getContent).collect(Collectors.toList());
long recordCount = search.getTotalHits();
return EsResult.of(data, recordCount);
}
@Override
public <T> EsResult<List<T>> search(Class<T> clazz, Query query) {
SearchHits<T> searchHit = elasticsearchTemplate.search(query, clazz);
List<T> data = searchHit.get().map(SearchHit::getContent).collect(Collectors.toList());
long recordCount = searchHit.getTotalHits();
return EsResult.of(data, recordCount);
}
@Override
public <T> EsResult<List<T>> search(Class<T> t, EsBaseReq request) {
//组装查询
Query query = EsQueryParse.convert2Query(request);
//组装分页
query.setPageable(EsPageable.getQueryPageable(request));
//组装排序
query.addSort(EsPageable.getQuerySort(request));
SearchHits<T> searchHits = elasticsearchTemplate.search(query, t);
List<T> data = searchHits.get().map(SearchHit::getContent).collect(Collectors.toList());
long recordCount = searchHits.getTotalHits();
return EsResult.of(data, recordCount);
}
@Override
public <T> EsResult<List<T>> dslSearch(Class<T> t, String dsl) {
StringQuery stringQuery = new StringQuery(dsl);
SearchHits<T> searchHits = elasticsearchTemplate.search(stringQuery, t);
List<T> data = searchHits.get().map(SearchHit::getContent).collect(Collectors.toList());
long recordCount = searchHits.getTotalHits();
return EsResult.of(data, recordCount);
}
@Override
public <T> T sqlFindOne(Class<T> t, String sql) {
String queryStr = String.format("{\"query\":\"%s\", \"fetch_size\":1}", sql);
return restClientSearch(t, queryStr).stream().findFirst().orElse(null);
}
@Override
public <T> List<T> sqlSearch(Class<T> t, String sql) {
String queryStr = String.format("{\"query\":\"%s\"}", sql);
return restClientSearch(t, queryStr);
}
/**
* 使用restClient 查询数据
*
* @param t
* @param queryStr sql_dsl查询条件
* @return
*/
private <T> List<T> restClientSearch(Class<T> t, String queryStr) {
try {
Request request = new Request("POST", "/_xpack/sql");
request.setJsonEntity(queryStr);
Response response = restClient.performRequest(request);
String jsonData = EntityUtils.toString(response.getEntity());
SQLRestResponse sqlRestResponse = gson.fromJson(jsonData, SQLRestResponse.class);
SQLResult<T> sqlResult = ResultUtil.buildFetchSQLResult(sqlRestResponse, t, (SQLResult<T>) null);
return sqlResult.getDatas();
} catch (IOException e) {
e.printStackTrace();
}
return new ArrayList<>();
}
}
/**
* 对ElasticSearch 索引的服务类
*
* @date: 2020/8/10 00:01
*/
@Service
public class EsIndexServiceImpl implements EsIndexService {
@Resource
protected ElasticsearchOperations operations;
@Override
public <T> boolean createIndex(Class<T> t) {
IndexOperations indexOperations = operations.indexOps(t);
boolean createIndexRes = indexOperations.create();
if (!createIndexRes) {
return false;
}
Document document = indexOperations.createMapping();
return indexOperations.putMapping(document);
}
@Override
public <T> boolean deleteIndex(Class<T> t) {
return operations.indexOps(t).delete();
}
@Override
public <T> boolean reCreate(Class<T> t) {
IndexOperations indexOperations = operations.indexOps(t);
return indexOperations.delete() && indexOperations.create();
}
@Override
public <T> boolean indexExists(Class<T> t) {
return operations.indexOps(t).exists();
}
}
Junit
@Test
public void search2() {
EsDocBeanEntity one = createOne();
String id = one.getId();
EsDocBeanReq request = new EsDocBeanReq();
request.setId(id);
request.setPage(1);
request.setSize(3);
EsResult<List<EsDocBeanEntity>> search = esDataService.search(EsDocBeanEntity.class, request);
System.out.println("gson.toJson(search) = " + gson.toJson(search));
Assert.assertNotNull(search);
Assert.assertTrue(CollectionUtils.isNotEmpty(search.getData()));
deleteOne(one);
}
@Test
public void sqlSearch() {
String sql = null;
sql = "SELECT * FROM ems limit 10";
List<EsDocBeanEntity> esDocBeanEntityList = esDataService.sqlSearch(EsDocBeanEntity.class, sql);
System.out.println("gson.toJson(esDocBeanList) = " + gson.toJson(esDocBeanEntityList));
}
@Test
public void bbossQueryTest() {
//TODO 需要yml中配置bboss相关属性才可使用
ClientInterface clientUtil = ElasticSearchHelper.getRestClientUtil();
SQLResult<EsDocBeanEntity> sqlResult = clientUtil.fetchQuery(EsDocBeanEntity.class, "{\"query\": \"SELECT * FROM ems\"}");
List<EsDocBeanEntity> datas = sqlResult.getDatas();
System.out.println("gson.toJson(datas) = " + gson.toJson(datas));
}
private EsDocBeanEntity createOne() {
EsDocBeanEntity b1 = new EsDocBeanEntity("XX" + RandomUtils.nextInt(1, 10000), "XX" + RandomUtils.nextInt(1, 10000), "xxx" + RandomUtils.nextInt(1, 10000), RandomUtils.nextInt(1, 10000));
EsDocBeanEntity save = esDataService.save(b1);
try {
//延迟一下,等待es执行
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
return save;
}
private boolean deleteOne(EsDocBeanEntity esDocBeanEntity) {
return esDataService.delete(EsDocBeanEntity.class, esDocBeanEntity.getId());
}
Заключительные замечания:
Поскольку это первое издание, код относительно груб. Но цель все равно достигнута, а новые возможности нужно расширять позже. Это новый, так что я надеюсь дать мне легкий спрей...