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();