Xây Dựng Lớp Wrapper Elasticsearch Cho .NET Và Thực Hiện CRUD

Để bắt đầu tích hợp Elasticsearch vào dự án .NET, bước đầu tiên là cài đặt gói thư liệu chính thức thông qua NuGet. Gói cần thiết là Elastic.Clients.Elasticsearch. Sau khi cài đặt, chúng ta sẽ xây dựng một lớp trừ tượng để quản lý kết nối và các thao tác cơ bản.

Thiết Lập Thư Viện Core

Chúng ta sẽ tạo một project class library để chứa các logic kết nối. Đầu tiên là cấu hình Dependency Injection.

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Infrastructure.Elasticsearch;

namespace Infrastructure.Elasticsearch
{
    public static class ServiceCollectionExtensions
    {
        public static IServiceCollection AddElasticsearchClientService(this IServiceCollection services, IConfiguration configuration)
        {
            services.Configure<ElasticsearchConnectionConfig>(configuration.GetSection("ElasticsearchConnectionConfig"));
            services.AddSingleton<IElasticsearchClientService, ElasticsearchClientService>();
            return services;
        }
    }
}

Tiếp theo là định nghĩa interface cho các thao tác CRUD và tìm kiếm.

using Elastic.Clients.Elasticsearch;
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Infrastructure.Elasticsearch
{
    public interface IElasticsearchClientService
    {
        Task<IndexResponse> CreateDocumentAsync<T>(T entity, IndexName indexName, CancellationToken cancelToken = default);
        Task<GetResponse<T>> FetchDocumentAsync<T>(Id docId, Action<GetRequestDescriptor<T>> configure, CancellationToken cancelToken = default);
        Task<SearchResponse<T>> QueryDocumentsAsync<T>(Action<SearchRequestDescriptor<T>> configure, CancellationToken cancelToken = default);
        Task<UpdateResponse<T>> ModifyDocumentAsync<T, TPartial>(IndexName indexName, Id docId, Action<UpdateRequestDescriptor<T, TPartial>> configure, CancellationToken cancelToken = default);
        Task<DeleteResponse> RemoveDocumentAsync<T>(IndexName indexName, Id docId, CancellationToken cancelToken = default);
    }
}

Triển khai cụ thể của service sẽ xử lý việc khởi tạo client dựa trên cấu hình đơn节点 hoặc cluster.

using Elastic.Clients.Elasticsearch;
using Elastic.Transport;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Infrastructure.Elasticsearch
{
    public class ElasticsearchClientService : IElasticsearchClientService
    {
        private readonly ElasticsearchClient _client;
        private readonly ILogger<ElasticsearchClientService> _logger;

        public ElasticsearchClientService(IOptions<ElasticsearchConnectionConfig> config, ILogger<ElasticsearchClientService> logger)
        {
            _logger = logger;
            if (config.Value == null)
            {
                _logger.LogError("Cấu hình Elasticsearch không hợp lệ.");
                throw new ArgumentNullException(nameof(IOptions<ElasticsearchConnectionConfig>));
            }

            var nodes = config.Value.Nodes;
            ElasticsearchClientSettings clientSettings;

            if (nodes.Length == 1)
            {
                clientSettings = new ElasticsearchClientSettings(new Uri(nodes.First()));
            }
            else
            {
                var uriNodes = nodes.Select(url => new Uri(url)).ToArray();
                var nodePool = new StaticNodePool(uriNodes);
                clientSettings = new ElasticsearchClientSettings(nodePool);
            }

            _client = new ElasticsearchClient(clientSettings);
        }

        public async Task<DeleteResponse> RemoveDocumentAsync<T>(IndexName indexName, Id docId, CancellationToken cancelToken = default)
        {
            return await _client.DeleteAsync<T>(indexName, docId, ct: cancelToken);
        }

        public async Task<GetResponse<T>> FetchDocumentAsync<T>(Id docId, Action<GetRequestDescriptor<T>> configure, CancellationToken cancelToken = default)
        {
            return await _client.GetAsync(docId, configure, cancelToken);
        }

        public async Task<IndexResponse> CreateDocumentAsync<T>(T entity, IndexName indexName, CancellationToken cancelToken = default)
        {
            return await _client.IndexAsync(entity, indexName, cancelToken);
        }

        public async Task<SearchResponse<T>> QueryDocumentsAsync<T>(Action<SearchRequestDescriptor<T>> configure, CancellationToken cancelToken = default)
        {
            return await _client.SearchAsync(configure, cancelToken);
        }

        public async Task<UpdateResponse<T>> ModifyDocumentAsync<T, TPartial>(IndexName indexName, Id docId, Action<UpdateRequestDescriptor<T, TPartial>> configure, CancellationToken cancelToken = default)
        {
            return await _client.UpdateAsync<T, TPartial>(indexName, docId, configure, cancelToken);
        }
    }
}

Lớp cấu hình chứa danh sách các địa chỉ node.

namespace Infrastructure.Elasticsearch
{
    public class ElasticsearchConnectionConfig
    {
        /// <summary>
        /// Danh sách các node kết nối. Ví dụ: http://192.168.0.1:9200
        /// Nếu chỉ có một địa chỉ, hệ thống sẽ chạy ở chế độ đơn节点.
        /// </summary>
        public string[] Nodes { get; set; }
    }
}

Cấu Hình WebAPI

Trong project ASP.NET Core WebAPI, cập nhật file appsettings.json với thông tin kết nối.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ElasticsearchConnectionConfig": {
    "Nodes": [
      "http://192.168.0.168:9200",
      "http://192.168.0.168:9201",
      "http://192.168.0.168:9202"
    ]
  }
}

Đăng ký service trong Program.cs.

using Infrastructure.Elasticsearch;
using System.Reflection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo
    {
        Title = "Elasticsearch API",
        Description = "API quản lý dữ liệu Elasticsearch",
        Version = "v1",
    });
    string xmlPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Assembly.GetExecutingAssembly().GetName().Name + ".xml");
    options.IncludeXmlComments(xmlPath, true);
});

// Đăng ký Elasticsearch
builder.Services.AddElasticsearchClientService(builder.Configuration);

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1");
    });
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Triển Khai Controller

Tạo controller để xử lý các yêu cầu HTTP. Dưới đây là ví dụ quản lý danh sách sách.

using Elastic.Clients.Elasticsearch;
using ElasticsearchWebAPI.Models;
using Infrastructure.Elasticsearch;
using Microsoft.AspNetCore.Mvc;
using Field = Elastic.Clients.Elasticsearch.Field;
using HighlightField = Elastic.Clients.Elasticsearch.Core.Search.HighlightField;

namespace ElasticsearchWebAPI.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class BookCatalogController : ControllerBase
    {
        private readonly IElasticsearchClientService _elasticService;
        private readonly ILogger<BookCatalogController> _logger;

        public BookCatalogController(IElasticsearchClientService elasticService, ILogger<BookCatalogController> logger)
        {
            _elasticService = elasticService;
            _logger = logger;
        }

        [HttpGet("{id}")]
        public async Task<ActionResult> GetById([FromRoute] string id)
        {
            var result = await _elasticService.FetchDocumentAsync<BookDocument>(id, action => action.Index(nameof(BookDocument).ToLower()));
            if (result.IsValidResponse)
            {
                return Ok(result.Source);
            }
            return NotFound($"Không tìm thấy dữ liệu trong index {nameof(BookDocument).ToLower()}");
        }

        [HttpGet("Search")]
        public async Task<ActionResult> FindBooks([FromQuery] int pageNum, [FromQuery] int limit, [FromQuery] string keyword)
        {
            int from = (pageNum - 1) * limit;
            SearchResponse<BookDocument> result = await _elasticService.QueryDocumentsAsync<BookDocument>(action =>
            {
                Field searchField = new Field("all");
                action.Index(nameof(BookDocument).ToLower())
                .From(from)
                .Size(limit)
                .Query(q => q.Match(m => m.Field(f => f.All).Query(keyword)))
                .Highlight(config =>
                    config
                    .Fields(f =>
                    {
                        f.Add(searchField, new HighlightField()
                        {
                            MatchedFields = new[] { "all" },
                            PostTags = new[] { "</span>" },
                            PreTags = new[] { "<span style='color:red'>" }
                        });
                        return f;
                    }))
                .Sort(sort =>
                {
                    sort.Field(s => s.Id).Doc(s => s.Order(SortOrder.Asc));
                });
            });

            if (result.IsValidResponse)
            {
                var list = result.Hits.Select(item => item.Source).ToList();
                return Ok(list);
            }
            return NotFound();
        }

        [HttpPut("{id}")]
        public async Task<ActionResult> UpdateBook([FromRoute] string id, [FromBody] BookDocument book)
        {
            book.All = $"{book.Id} {book.Name} {book.Author} {book.PublicationTime}";
            var response = await _elasticService.ModifyDocumentAsync<BookDocument, BookDocument>(nameof(BookDocument).ToLower(), id, u => u.Doc(book));
            if (response.IsValidResponse)
            {
                return Ok("Cập nhật thành công.");
            }
            return NotFound();
        }

        [HttpPost]
        public async Task<ActionResult> CreateBook([FromBody] BookDocument book)
        {
            book.All = $"{book.Id} {book.Name} {book.Author} {book.PublicationTime}";
            var response = await _elasticService.CreateDocumentAsync<BookDocument>(book, nameof(BookDocument).ToLower());
            if (response.IsValidResponse)
            {
                return Ok($"Đã thêm document với ID {response.Id}.");
            }
            return BadRequest();
        }

        [HttpDelete("{id}")]
        public async Task<ActionResult> DeleteBook([FromRoute] string id)
        {
            var response = await _elasticService.RemoveDocumentAsync<BookDocument>(nameof(BookDocument).ToLower(), id);
            if (response.IsValidResponse)
            {
                return Ok("Xóa thành công.");
            }
            return NotFound();
        }
    }
}

Định Nghĩa Model

Đối tượng dữ liệu cần ánh xạ phù hợp với cấu trúc lưu trữ.

namespace ElasticsearchWebAPI.Models
{
    public class BookDocument
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Author { get; set; }
        public string PublicationTime { get; set; }
        /// <summary>
        /// Trường hợp nhất chứa nội dung tất cả các trường để tìm kiếm toàn văn
        /// </summary>
        public string All { get; set; }
    }
}

Quản Lý Index Và Shards

Khi thực hiện ghi dữ liệu lần đầu, Elasticsearch có thể tự động tạo mapping. Tuy nhiên, để tối ưu hiệu năng và khả năng mở rộng, nên định nghĩa mapping thủ công. Ví dụ dưới đây thiết lập 3 shards và 1 replica.

PUT /books
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1
  },
  "mappings": {
    "properties": {
      "id": { "type": "text" },
      "name": { "type": "text" },
      "author": { "type": "text" },
      "publicationTime": { "type": "text" },
      "all": { "type": "text" }
    }
  }
}

Với cấu hình 3 shards và 1 replica, tổng cộng sẽ có 6 phân vùng dữ liệu. Trong môi trường cluster có 3节点, việc设置 2 replicas có thể được cân nhắc để dự phòng cho khả năng mở rộng ngang hàng trong tương lai.

Thẻ: Elasticsearch aspnet-core elastic-clients dependency-injection full-text-search

Đăng vào ngày 29 tháng 5 lúc 08:02