yzk学英语项目-搜索服务器

94 阅读3分钟

Elastic Search是一个独立运行的服务器,对外提供HTTP接口。我们一般借助于封装好的NuGet包NEST来调用Elastic Search的接口

功能:搜索音频原文中的字

领域层

实体类

public record Episode(Guid Id, string CnName, string EngName, string PlainSubtitle, Guid AlbumId);

响应类

////搜索到的多条Episode、匹配到的总条数
public record SearchEpisodesResponse(IEnumerable<Episode> Episodes, long TotalCount);

仓储接口

public interface ISearchRepository
    {
        public Task UpsertAsync(Episode episode);
        public Task DeleteAsync(Guid episodeId);
        //从中文标题、英文标题、正文中搜,对原文中匹配到的进行高亮显示,如果没有预览内容,则显示前50个字,再返回给前端
        public Task<SearchEpisodesResponse> SearchEpisodes(string keyWord, int pageIndex, int PageSize);
    }

基础设施层

仓储接口实现

public class SearchRepository : ISearchRepository
    {
    	//注入IElasticClient服务
        private readonly IElasticClient elasticClient;

        public SearchRepository(IElasticClient elasticClient)
        {
            this.elasticClient = elasticClient;
        }

        public Task DeleteAsync(Guid episodeId)
        {
            elasticClient.DeleteByQuery<Episode>(q => q
               .Index("episodes")
               .Query(rq => rq.Term(f => f.Id, "elasticsearch.pm")));
            //因为有可能文档不存在,所以不检查结果
            //如果Episode被删除,则把对应的数据也从Elastic Search中删除
            return elasticClient.DeleteAsync(new DeleteRequest("episodes", episodeId));
        }

        public async Task<SearchEpisodesResponse> SearchEpisodes(string Keyword, int PageIndex, int PageSize)
        {
            int from = PageSize * (PageIndex - 1);
            string kw = Keyword;
            Func<QueryContainerDescriptor<Episode>, QueryContainer> query = (q) =>
                          q.Match(mq => mq.Field(f => f.CnName).Query(kw))
                          || q.Match(mq => mq.Field(f => f.EngName).Query(kw))
                          || q.Match(mq => mq.Field(f => f.PlainSubtitle).Query(kw));
            Func<HighlightDescriptor<Episode>, IHighlight> highlightSelector = h => h
                .Fields(fs => fs.Field(f => f.PlainSubtitle));
            var result = await this.elasticClient.SearchAsync<Episode>(s => s.Index("episodes").From(from)
                .Size(PageSize).Query(query).Highlight(highlightSelector));
            if(!result.IsValid)
            {
                throw result.OriginalException;
            }
            List<Episode> episodes = new List<Episode>();
            foreach (var hit in result.Hits)
            {
                string highlightedSubtitle;
                //如果没有预览内容,则显示前50个字
                if (hit.Highlight.ContainsKey("plainSubtitle"))
                {
                    highlightedSubtitle = string.Join("\r\n", hit.Highlight["plainSubtitle"]);
                }
                else
                {
                    highlightedSubtitle = hit.Source.PlainSubtitle.Cut(50);
                }
                var episode = hit.Source with { PlainSubtitle = highlightedSubtitle };
                episodes.Add(episode);
            }
            return new SearchEpisodesResponse(episodes, result.Total);
        }

        public async Task UpsertAsync(Episode episode)
        {
            var response = await elasticClient.IndexAsync(episode, idx => idx.Index("episodes").Id(episode.Id));//Upsert:Update or Insert
            if (!response.IsValid)
            {
                throw new ApplicationException(response.DebugInformation);
            }
        }
    }

模块自注册

在Program.cs中注册Elastic Search客户端的服务

builder.Services.AddScoped<IElasticClient>(sp =>
{
	string url = "http://用户名:密码@localhost:9200/";
	var settings = new ConnectionSettings(url);
	return new ElasticClient(settings);
});

完整代码

internal class ModuleInitializer : IModuleInitializer
    {
        public void Initialize(IServiceCollection services)
        {
            services.AddHttpClient();
            services.AddScoped<IElasticClient>(sp =>
            {
                var option = sp.GetRequiredService<IOptions<ElasticSearchOptions>>();
                var settings = new ConnectionSettings(option.Value.Url);
                return new ElasticClient(settings);
            });
            services.AddScoped<ISearchRepository, SearchRepository>();
        }
    }

ES的配置类

public class ElasticSearchOptions
{
    public Uri Url { get; set; }
}

应用层

集成事件

在英语听力后台的应用层,我们会在新建一个音频的时候发布一个ListeningEpisode.Created集成事件(在英语听力后台的领域层,实体类Episode的Build方法中,发出一个领域事件。然后在其应用层监听领域事件,再将它发布为ListeningEpisode.Created集成事件)。

在本搜索服务的应用层,编写一个监听这个集成事件的处理器ListeningEpisodeUpsertEventHandler ,然后把原文等数据写入Elastic Search

[EventName("ListeningEpisode.Deleted")]
[EventName("ListeningEpisode.Hidden")]//被隐藏也看作删除
public class ListeningEpisodeDeletedEventHandler : DynamicIntegrationEventHandler
{
    private readonly ISearchRepository repository;

    public ListeningEpisodeDeletedEventHandler(ISearchRepository repository)
    {
        this.repository = repository;
    }

    public override Task HandleDynamic(string eventName, dynamic eventData)
    {
        Guid id = Guid.Parse(eventData.Id);
        return repository.DeleteAsync(id);
    }
}
[EventName("ListeningEpisode.Created")]
[EventName("ListeningEpisode.Updated")]
public class ListeningEpisodeUpsertEventHandler : DynamicIntegrationEventHandler
{
    private readonly ISearchRepository repository;

    public ListeningEpisodeUpsertEventHandler(ISearchRepository repository)
    {
        this.repository = repository;
    }

    public override Task HandleDynamic(string eventName, dynamic eventData)
    {
        Guid id = Guid.Parse(eventData.Id);
        string cnName = eventData.Name.Chinese;
        string engName = eventData.Name.English;
        Guid albumId = Guid.Parse(eventData.AlbumId);
        List<string> sentences = new List<string>();
        foreach (var sentence in eventData.Sentences)
        {
            sentences.Add(sentence.Value);
        }
        string plainSentences = string.Join("\r\n", sentences);
        Episode episode = new Episode(id, cnName, engName, plainSentences, albumId);
        return repository.UpsertAsync(episode);
    }
}

请求及校验类

//搜索数据,根据(关键词,页码,页容量)搜索
public record SearchEpisodesRequest(string Keyword, int PageIndex, int PageSize);

public class SearchEpisodesRequestValidator : AbstractValidator<SearchEpisodesRequest>
{
    public SearchEpisodesRequestValidator()
    {
        RuleFor(e => e.Keyword).NotNull().MinimumLength(2).MaximumLength(100);
        RuleFor(e => e.PageIndex).GreaterThan(0);//页号从1开始
        RuleFor(e => e.PageSize).GreaterThanOrEqualTo(5);
    }
}

控制器

搜索接口:为了供前端调用接口完成搜索功能,我们开发了SearchController这个控制器类

[ApiController]
[Route("[controller]/[action]")]
public class SearchController : ControllerBase
{

    private readonly ISearchRepository repository;
    private readonly IEventBus eventBus;

    public SearchController(ISearchRepository repository, IEventBus eventBus)
    {
        this.repository = repository;
        this.eventBus = eventBus;
    }

    [HttpGet]
    public Task<SearchEpisodesResponse> SearchEpisodes([FromQuery] SearchEpisodesRequest req)
    {
        return repository.SearchEpisodes(req.Keyword, req.PageIndex, req.PageSize);
    }

    [HttpPut]
    public async Task<IActionResult> ReIndexAll()
    {
        //避免耦合,这里发送ReIndexAll的集成事件
        //所有向搜索系统贡献数据的系统都可以响应这个事件,重新贡献数据
        eventBus.Publish("SearchService.ReIndexAll", null);
        return Ok();
    }
}

服务与管道模型

从数据库读取ES的配置

builder.Services.Configure<ElasticSearchOptions>(builder.Configuration.GetSection("ElasticSearch"));

完整代码

var builder = WebApplication.CreateBuilder(args);
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
    LogFilePath = "e:/temp/SearchService.log",
    EventBusQueueName = "SearchService.WebAPI"
});
// Add services to the container.

builder.Services.Configure<ElasticSearchOptions>(builder.Configuration.GetSection("ElasticSearch"));

builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "SearchService.WebAPI", Version = "v1" });
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "SearchService.WebAPI v1"));
}
app.UseZackDefault();
app.MapControllers();

app.Run();