yzk学英语项目-听力服务

79 阅读20分钟

功能

  1. 提供供听力前台界面的数据接口、增删改查
  2. 有两个应用层项目:后台应用层Listening.Admin.WebAPI(增删改查的微服务)、前台应用层Listening.Main.WebAPI(普通用户访问的界面提供数据的微服务)。共享同一个领域层、基础设施层

领域层

设计思路

查询:仓储接口里面,是和业务结合紧密的代码,这里全部是满足各种业务需求的查询

更新、隐藏、显示、删除:更新各个子字段的方法、数据的隐藏显示方法、删除方法(软删除)写在了实体类中,控制器的更新、数据隐藏显示、删除方法里面直接调用实体类中的更新方法

新增、排序:领域服务里面,是新增数据、排序数据,这种通用逻辑,和业务关系不大的

实体类

实体类(充血模型)

分类(Category)、专辑(Album)、音频(Episode)3个实体。由于我们可以直接访问某个专辑或者某个音频,因此我们这里把这3个实体放到3个聚合之中。

Album和Category属于两个聚合,因此它们之间只能通过聚合根的标识符引用,因此我们只为Album类定义了代表Category主键的CategoryId属性,而没有定义Category类型的主键。Album类,通过CategoryId属性引用Category实体Id(不是实体对象)。同理,Episode类,通过CategoryId属性引用Album实体Id

分类>专辑>音频(包含关系)

注意,设计为三个独立的聚合,因此它们之间只能通过聚合根的标识符引用,而不是通过对象引用。只在子添加父聚合的Id即可!配置类、DbContext不需要为此进行单独配置

Category、album、Episode都是聚合根,而不像订单是聚合根、而订单明细是子实体一样。因为订单和订单明细是整体和部分的关系,一起出现,不会把订单明细单独访问,但是Episode是可以单独访问的,而且Episode还可以移动到其他album。因此不能在这三个实体之间引用。具体讨论见:

DDD实践问题之 - 关于论坛的帖子回复统计信息的更新的思考-阿里云开发者社

public record Album : AggregateRootEntity, IAggregateRoot
    {
        private Album() { }

        /// <summary>
        /// 用户是否可见(完善后才显示,或者已经显示了,但是发现内部有问题,就先隐藏,调整了再发布)
        /// </summary>
        public bool IsVisible { get; private set; }

        /// <summary>
        /// 标题
        /// </summary>
        public MultilingualString Name { get; private set; }

        /// <summary>
        /// 列表中的显示序号
        /// </summary>
        public int SequenceNumber { get; private set; }

        public Guid CategoryId { get; private set; }

        public static Album Create(Guid id, int sequenceNumber, MultilingualString name, Guid categoryId)
        {
            Album album = new Album();
            album.Id = id;
            album.SequenceNumber = sequenceNumber;
            album.Name = name;
            album.CategoryId = categoryId;
            album.IsVisible = false;//Album新建以后默认不可见,需要手动Show
            return album;
        }
        public Album ChangeSequenceNumber(int value)
        {
            this.SequenceNumber = value;
            return this;
        }

        public Album ChangeName(MultilingualString value)
        {
            this.Name = value;
            return this;
        }
        public Album Hide()
        {
            this.IsVisible = false;
            return this;
        }
        public Album Show()
        {
            this.IsVisible = true;
            return this;
        }
    }
public record Category : AggregateRootEntity, IAggregateRoot
    {
        private Category() { }

        /// <summary>
        /// 在所有Category中的显示序号,越小越靠前
        /// </summary>
        public int SequenceNumber { get; private set; }
        public MultilingualString Name { get; private set; }

        /// <summary>
        /// 封面图片。现在一般都不会直接把图片保存到数据库中(Blob),而是只是保存图片的路径。
        /// </summary>
        public Uri CoverUrl { get; private set; }

        public static Category Create(Guid id, int sequenceNumber, MultilingualString name, Uri coverUrl)
        {
            Category category = new();
            category.Id = id;
            category.SequenceNumber = sequenceNumber;
            category.Name = name;
            category.CoverUrl = coverUrl;
            //category.AddDomainEvent(new CategoryCreatedEventArgs { NewObj = category });
            return category;
        }

        public Category ChangeSequenceNumber(int value)
        {
            this.SequenceNumber = value;
            return this;
        }

        public Category ChangeName(MultilingualString value)
        {
            this.Name = value;
            return this;
        }

        public Category ChangeCoverUrl(Uri value)
        {
            //todo: 做项目的时候,不管这个事件是否有被用到,都尽量publish。
            this.CoverUrl = value;
            return this;
        }
    }

音频Episode。指的一个音频片段,不是一句话

EF Core中的实体和DDD的中的实体不一样。DDD中的实体是Data Object,是和数据库表以及字段一一对应的,EF Core中的实体更像领域模型,DO是藏在EF Core框架中的。

网站上线后,又提出来一个需求“非m4a文件上传后先转码再发布”,如果用面向数据库的开发,就要在Episode表搞一个字段“表示”是否已发布,在发布之前,AudioUrl等属性都是无效的。

按照DDD的思想,就额外拆分出一个“待发布Episode”,转换完成后,再把“待发布Episode”的数据导入Episode,这样就不用对Episode实体做改变。

我们应该定义一个Sentence[]类型的属性,然后再编写EF Core的ValueConverter来完成数据库中的字幕文本和Sentence[]之间的转换。不过这样设计的话,就会导致我们每次从数据库中读取Episode的时候,都会执行字幕文本转换的代码,这会降低程序的性能。因此我们做了一个折衷的设计,Episode中直接定义字符串类型的Subtitle属性用来保存字幕原文。我们再定义一个ParseSubtitle方法来完成字幕文本到Sentence[]的转换,当程序需要转换字幕原文时,我们再调用ParseSubtitle方法。

注意,ParseSubtitle即解析字幕到值对象的方法仍然抽离出去,在实体类中只是调用

Builder嵌套类型:简化创建对象的赋值,发出对象被创建的事件

public record Episode : AggregateRootEntity, IAggregateRoot
{
    private Episode() { }
    public int SequenceNumber { get; private set; }//序号
    public MultilingualString Name { get; private set; }//标题
    public Guid AlbumId { get; private set; }//专辑Id,因为Episode和Album都是聚合根,因此不能直接做对象引用。
    public Uri AudioUrl { get; private set; }//音频路径

    /// <summary>
    ///这是音频的实际长度(秒)
    ///因为IE、旧版Edge、部分手机内置浏览器(小米等)中对于部分音频,
    ///计算的duration以及currentTime和实际的不一致,因此需要根据服务器端
    ///计算出来的实际长度,在客户端做按比例校正
    ///所以服务器端需要储存这个,以便给到浏览器
    /// </summary>
    public double DurationInSecond { get; private set; }//音频时长(秒数)

    //因为启用了<Nullable>enable</Nullable>,所以string是不可空,Migration会默认这个,string?是可空
    public string Subtitle { get; private set; }//原文字幕内容
    public string SubtitleType { get; private set; }//原文字幕格式

    /// <summary>
    /// 用户是否可见(如果发现内部有问题,就先隐藏)
    /// </summary>
    public bool IsVisible { get; private set; }
    /*
    public static Episode Create(Guid id, int sequenceNumber, MultilingualString name, Guid albumId, Uri audioUrl,
        double durationInSecond, string subtitleType, string subtitle)
    {
        var parser = SubtitleParserFactory.GetParser(subtitleType);
        if (parser == null)
        {
            throw new ArgumentOutOfRangeException(nameof(subtitleType), $"subtitleType={subtitleType} is not supported.");
        }

        //新建的时候默认可见
        Episode episode = new Episode()
        {
            Id = id,
            AlbumId = albumId,
            DurationInSecond = durationInSecond,
            AudioUrl = audioUrl,
            Name = name,
            SequenceNumber = sequenceNumber,
            Subtitle = subtitle,
            SubtitleType = subtitleType,
            IsVisible = true
        };
        episode.AddDomainEvent(new EpisodeCreatedEvent(episode));
        return episode;
    }*/

    public Episode ChangeSequenceNumber(int value)
    {
        this.SequenceNumber = value;
        this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
        return this;
    }

    public Episode ChangeName(MultilingualString value)
    {
        this.Name = value;
        this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
        return this;
    }

    public Episode ChangeSubtitle(string subtitleType, string subtitle)
    {
        var parser = SubtitleParserFactory.GetParser(subtitleType);
        if (parser == null)
        {
            throw new ArgumentOutOfRangeException(nameof(subtitleType), $"subtitleType={subtitleType} is not supported.");
        }
        this.SubtitleType = subtitleType;
        this.Subtitle = subtitle;
        this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
        return this;
    }

    public Episode Hide()
    {
        this.IsVisible = false;
        this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
        return this;
    }
    public Episode Show()
    {
        this.IsVisible = true;
        this.AddDomainEventIfAbsent(new EpisodeUpdatedEvent(this));
        return this;
    }

    public override void SoftDelete()
    {
        base.SoftDelete();
        this.AddDomainEvent(new EpisodeDeletedEvent(this.Id));
    }

    public IEnumerable<Sentence> ParseSubtitle()
    {
        var parser = SubtitleParserFactory.GetParser(this.SubtitleType);
        return parser.Parse(this.Subtitle);
    }
    public class Builder
    {
        private Guid id;
        private int sequenceNumber;
        private MultilingualString name;
        private Guid albumId;
        private Uri audioUrl;
        private double durationInSecond;
        private string subtitle;
        private string subtitleType;
        public Builder Id(Guid value)
        {
            this.id = value;
            return this;
        }
        public Builder SequenceNumber(int value)
        {
            this.sequenceNumber = value;
            return this;
        }
        public Builder Name(MultilingualString value)
        {
            this.name = value;
            return this;
        }
        public Builder AlbumId(Guid value)
        {
            this.albumId = value;
            return this;
        }
        public Builder AudioUrl(Uri value)
        {
            this.audioUrl = value;
            return this;
        }
        public Builder DurationInSecond(double value)
        {
            this.durationInSecond = value;
            return this;
        }
        public Builder Subtitle(string value)
        {
            this.subtitle = value;
            return this;
        }
        public Builder SubtitleType(string value)
        {
            this.subtitleType = value;
            return this;
        }
        public Episode Build()
        {
            if (id == Guid.Empty)
            {
                throw new ArgumentOutOfRangeException(nameof(id));
            }
            if (name == null)
            {
                throw new ArgumentNullException(nameof(name));
            }
            if (albumId == Guid.Empty)
            {
                throw new ArgumentOutOfRangeException(nameof(albumId));
            }
            if (audioUrl == null)
            {
                throw new ArgumentNullException(nameof(audioUrl));
            }
            if (durationInSecond <= 0)
            {
                throw new ArgumentOutOfRangeException(nameof(durationInSecond));
            }
            if (subtitle == null)
            {
                throw new ArgumentNullException(nameof(subtitle));
            }
            if (subtitleType == null)
            {
                throw new ArgumentNullException(nameof(subtitleType));
            }
            Episode e = new Episode();
            e.Id = id;
            e.SequenceNumber = sequenceNumber;
            e.Name = name;
            e.AlbumId = albumId;
            e.AudioUrl = audioUrl;
            e.DurationInSecond = durationInSecond;
            e.Subtitle = subtitle;
            e.SubtitleType = subtitleType;
            e.IsVisible = true;
            e.AddDomainEvent(new EpisodeCreatedEvent(e));
            return e;
        }
    }   
}

我们可以把Episode的所有属性都设置为可读可写的,然后把Episode的无参构造方法设置为公开的,不过这样就会导致对象有处于非法状态的可能性。这里采用Builder模式编写了专门用来构建Episode对象的Builder类,Builder类是定义在Episode类内部的类,因此Builder类可以为Episode类的私有属性赋值。

var builder = new Episode.Builder();
builder.Id(id).SequenceNumber(maxSeq + 1).Name(name).AlbumId(albumId)
    .AudioUrl(audioUrl).DurationInSecond(durationInSecond)
    .SubtitleType(subtitleType).Subtitle(subtitle);
Episode episode = builder.Build();

值对象

为了表示字幕文件中每一句话的起止时间和原文内容,我们定义了一个值对象Sentence

public record Sentence(TimeSpan StartTime, TimeSpan EndTime, string Value);

防腐层

虽然这个是在实体类中调用的,但是只注入接口即可。所以更规范的做法是在基础设施层写实现。

用于解析给定的字幕文件,将subtitle字幕解析为IEnumerable

为了能够适应不同格式的字幕文件的解析,我们定义了字幕解析器接口ISubtitleParser

interface ISubtitleParser
    {
        /// <summary>
        /// 本解析器是否能够解析typeName这个类型的字幕
        /// </summary>
        /// <param name="typeName"></param>
        /// <returns></returns>
        bool Accept(string typeName);

        /// <summary>
        /// 解析这个字幕subtitle
        /// </summary>
        /// <param name="subtitle"></param>
        /// <returns></returns>
        IEnumerable<Sentence> Parse(string subtitle);
    }

json解析器:json格式

class JsonParser : ISubtitleParser
    {
        public bool Accept(string typeName)
        {
            return typeName.Equals("json", StringComparison.OrdinalIgnoreCase);
        }

        public IEnumerable<Sentence> Parse(string subtitle)
        {
            return JsonSerializer.Deserialize<IEnumerable<Sentence>>(subtitle);
        }
    }

lrc解析器:parser for *.lrc files。lrc格式的开源库Opportunity.LrcParser.Lyrics解析

class LrcParser : ISubtitleParser
    {
        public bool Accept(string typeName)
        {
            return typeName.Equals("lrc", StringComparison.OrdinalIgnoreCase);
        }

        public IEnumerable<Sentence> Parse(string subtitle)
        {
            var lyrics = Lyrics.Parse(subtitle);
            if (lyrics.Exceptions.Count > 0)
            {
                throw new ApplicationException("lrc解析失败");
            }
            lyrics.Lyrics.PreApplyOffset();//应用上[offset:500]这样的偏移
            return FromLrc(lyrics.Lyrics);
        }

        private static Sentence[] FromLrc(Lyrics<Line> lyrics)
        {
            var lines = lyrics.Lines;
            Sentence[] sentences = new Sentence[lines.Count];
            for (int i = 0; i < lines.Count - 1; i++)
            {
                var line = lines[i];
                var nextLine = lines[i + 1];
                Sentence sentence = new Sentence(line.Timestamp.TimeOfDay, nextLine.Timestamp.TimeOfDay, line.Content);
                sentences[i] = sentence;
            }
            //last line
            var lastLine = lines.Last();
            TimeSpan lastLineStartTime = lastLine.Timestamp.TimeOfDay;
            //lrc没有结束时间,就极端假定最后一句耗时1分钟
            TimeSpan lastLineEndTime = lastLineStartTime.Add(TimeSpan.FromMinutes(1));
            var lastSentence = new Sentence(lastLineStartTime, lastLineEndTime, lastLine.Content);
            sentences[sentences.Count() - 1] = lastSentence;

            return sentences;
        }
    }

srt解析器:parser for *.srt files and *.vtt files。SubtitlesParser开源库解析

 class SrtParser : ISubtitleParser
    {
        public bool Accept(string typeName)
        {
            return typeName.Equals("srt", StringComparison.OrdinalIgnoreCase)
                || typeName.Equals("vtt", StringComparison.OrdinalIgnoreCase);
        }

        public IEnumerable<Sentence> Parse(string subtitle)
        {
            var srtParser = new SubtitlesParser.Classes.Parsers.SubParser();
            using (MemoryStream ms = new MemoryStream(Encoding.UTF8.GetBytes(subtitle)))
            {
                var items = srtParser.ParseStream(ms);
                return items.Select(s => new Sentence(TimeSpan.FromMilliseconds(s.StartTime),
                    TimeSpan.FromMilliseconds(s.EndTime), String.Join(" ", s.Lines)));
            }
        }
    }

用于创建解析器的工厂类

SubtitleParserFactory是用来根据字幕的格式名称来获取解析器类的工厂类,我们调用这个类的GetParser方法就可以获得指定格式的ISubtitleParser接口实现类。

static class SubtitleParserFactory
    {
        private static List<ISubtitleParser> parsers = new();

        static SubtitleParserFactory()
        {
            //通过反射扫描本程序集中的所有实现了ISubtitleParser接口的类,创建一个包含所有解析器的列表
            var parserTypes = typeof(SubtitleParserFactory).Assembly.GetTypes().Where(t => typeof(ISubtitleParser).IsAssignableFrom(t) && !t.IsAbstract);

            //创建这些对象,添加到parsers
            foreach (var parserType in parserTypes)
            {
                ISubtitleParser parser = (ISubtitleParser)Activator.CreateInstance(parserType);
                parsers.Add(parser);
            }
        }
    	//返回一个可以解析这种格式的解析器
        public static ISubtitleParser? GetParser(string typeName)
        {
            //遍历所有解析器,挨个问他们“能解析这个格式吗”,碰到一个能解析的,就会把解析器返回
            foreach (var parser in parsers)
            {
                if (parser.Accept(typeName))
                {
                    return parser;
                }
            }
            return null;
        }
    }

上端调用

//使用时
var parser=SubtitleParserFactory.GetParser("lrc");
parser.Parse(...);

领域事件

事件名称说明
EpisodeCreatedEvent对象创建
EpisodeDeletedEvent对象删除
EpisodeUpdatedEvent对象修改

领域事件

public record EpisodeCreatedEvent(Episode Value) : INotification;

public record EpisodeDeletedEvent(Guid Id) : INotification;

public record EpisodeUpdatedEvent(Episode Value) : INotification;

仓储接口

public interface IListeningRepository
    {
        public Task<Category?> GetCategoryByIdAsync(Guid categoryId);
        public Task<Category[]> GetCategoriesAsync();
        public Task<int> GetMaxSeqOfCategoriesAsync();//获取最大序号
        public Task<Album?> GetAlbumByIdAsync(Guid albumId);
        public Task<int> GetMaxSeqOfAlbumsAsync(Guid categoryId);
        public Task<Album[]> GetAlbumsByCategoryIdAsync(Guid categoryId);
        public Task<Episode?> GetEpisodeByIdAsync(Guid episodeId);
        public Task<int> GetMaxSeqOfEpisodesAsync(Guid albumId);
        public Task<Episode[]> GetEpisodesByAlbumIdAsync(Guid albumId);
    }

领域服务

领域服务里面是添加一条数据、对数据排序。只改变对象,并没有保存到数据库

public class ListeningDomainService
    {
        private readonly IListeningRepository repository;

        public ListeningDomainService(IListeningRepository repository)
        {
            this.repository = repository;
        }

        public async Task<Album> AddAlbumAsync(Guid categoryId, MultilingualString name)
        {
            int maxSeq = await repository.GetMaxSeqOfAlbumsAsync(categoryId);
            var id = Guid.NewGuid();
            return Album.Create(id, maxSeq + 1, name, categoryId);
        }

        public async Task SortAlbumsAsync(Guid categoryId, Guid[] sortedAlbumIds)
        {
            var albums = await repository.GetAlbumsByCategoryIdAsync(categoryId);
            var idsInDB = albums.Select(a => a.Id);
            if (!idsInDB.SequenceIgnoredEqual(sortedAlbumIds))
            {
                throw new Exception($"提交的待排序Id中必须是categoryId={categoryId}分类下所有的Id");
            }

            int seqNum = 1;
            //一个in语句一次性取出来更快,不过在非性能关键节点,业务语言比性能更重要
            foreach (Guid albumId in sortedAlbumIds)
            {
                var album = await repository.GetAlbumByIdAsync(albumId);
                if (album == null)
                {
                    throw new Exception($"albumId={albumId}不存在");
                }
                album.ChangeSequenceNumber(seqNum);//顺序改序号
                seqNum++;
            }
        }

        public async Task<Category> AddCategoryAsync(MultilingualString name, Uri coverUrl)
        {
            int maxSeq = await repository.GetMaxSeqOfCategoriesAsync();
            var id = Guid.NewGuid();
            return Category.Create(id, maxSeq + 1, name, coverUrl);
        }

        public async Task SortCategoriesAsync(Guid[] sortedCategoryIds)
        {
            var categories = await repository.GetCategoriesAsync();
            var idsInDB = categories.Select(a => a.Id);
            if (!idsInDB.SequenceIgnoredEqual(sortedCategoryIds))
            {
                throw new Exception("提交的待排序Id中必须是所有的分类Id");
            }
            int seqNum = 1;
            //一个in语句一次性取出来更快,不过在非性能关键节点,业务语言比性能更重要
            foreach (Guid catId in sortedCategoryIds)
            {
                var cat = await repository.GetCategoryByIdAsync(catId);
                if (cat == null)
                {
                    throw new Exception($"categoryId={catId}不存在");
                }
                cat.ChangeSequenceNumber(seqNum);//顺序改序号
                seqNum++;
            }
        }

        public async Task<Episode> AddEpisodeAsync(MultilingualString name,
            Guid albumId, Uri audioUrl, double durationInSecond,
            string subtitleType, string subtitle)
        {
            int maxSeq = await repository.GetMaxSeqOfEpisodesAsync(albumId);
            var id = Guid.NewGuid();
            /*
            Episode episode = Episode.Create(id, maxSeq + 1, name, albumId,
                audioUrl,durationInSecond, subtitleType, subtitle);*/
            var builder = new Episode.Builder();
            builder.Id(id).SequenceNumber(maxSeq + 1).Name(name).AlbumId(albumId)
                .AudioUrl(audioUrl).DurationInSecond(durationInSecond)
                .SubtitleType(subtitleType).Subtitle(subtitle);
            return builder.Build();
        }

        public async Task SortEpisodesAsync(Guid albumId, Guid[] sortedEpisodeIds)
        {
            var episodes = await repository.GetEpisodesByAlbumIdAsync(albumId);
            var idsInDB = episodes.Select(a => a.Id);
            if (!sortedEpisodeIds.SequenceIgnoredEqual(idsInDB))
            {
                throw new Exception($"提交的待排序Id中必须是albumId={albumId}专辑下所有的Id");
            }

            int seqNum = 1;
            foreach (Guid episodeId in sortedEpisodeIds)
            {
                var episode = await repository.GetEpisodeByIdAsync(episodeId);
                if (episode == null)
                {
                    throw new Exception($"episodeId={episodeId}不存在");
                }
                episode.ChangeSequenceNumber(seqNum);//顺序改序号
                seqNum++;
            }
        }
    }

模块自注册

class ModuleInitializer : IModuleInitializer
    {
        public void Initialize(IServiceCollection services)
        {
            services.AddScoped<ListeningDomainService>();
        }
    }
领域层详细

1、Category实体

分类Category

属性名类型功能说明
SequenceNumber序号可以通过修改序号来改变显示顺序,越小越靠前
NameMultilingualString值对象标题可以是中英文
CoverUrl封面图片现在一般都不会直接把图片以Blob保存到数据库中,而是只是保存图片的路径
方法名功能说明
Create静态方法:创建一个Category实体
ChangeSequenceNumber修改序号
ChangeName修改名字
ChangeCoverUrl修改封面做项目时,不管这个事件是否被用到,尽量发出领域事件

2、Album实体

专辑Album

属性名类型功能说明
NameMultilingualString
IsVisible用户是否可见完善后才显示,或者已经显示了,但是发现内部有问题,就先隐藏,调整了再发布
SequenceNumber列表中的显示序号
CategoryIdGuid聚合之间,通过聚合根来引用
方法名功能说明
Hide、Show隐藏、显示数据有问题时,可以暂时隐藏(上架、下架用)
Create
ChangeSequenceNumber
ChangeName

3、Episode实体

属性名类型功能说明
SequenceNumber序号音频,这个先后顺序很重要
Name标题
AlbumId专辑Id
AudioUrlUri音频的路径
DurationInSeconddouble音频时长前端需要,用作冗余,省去每次拿到音频再计算
Subtitlestring原文字幕内容直接是src、lrt字幕文件的原文文本,这样存是基于性能优化的考虑
SubtitleTypestring原文字幕格式src、lrt
IsVisible用户是否可见如果发现内部有问题,就先隐藏

Sentence值对象

属性名类型说明
StartTime、EndTimeTimeSpan
Valuestring

方法

方法名功能返回类型说明
ParseSubtitle将字幕文件解析为句子对象IEnumerable
ChangeSequenceNumber修改序号
ChangeSubtitle修改字幕文件内容
ChangeName修改标题
Hide、Show上架、下架
SoftDelete软删除

仓储层IListeningRepository

方法名说明补充
GetCategoryByIdAsync获得指定Id的类别
GetCategoriesAsync获得所有类别
GetMaxSeqOfCategoriesAsync获取最大序号
GetAlbumByIdAsync获得指定Id的专辑
GetMaxSeqOfAlbumsAsync获得指定categoryId下专辑的最大序号因为新增一个Album序号要递增
GetAlbumsByCategoryIdAsync获取CategoryId下的所有专辑
GetEpisodeByIdAsync根据episodeId获得音频
GetMaxSeqOfEpisodesAsync获得albumId下音频的最大序号
GetEpisodesByAlbumIdAsync获得albumId下的所有音频

领域服务ListeningDomainService

方法名说明参数补充
AddAlbumAsync在指定categoryId下增加一个Album专辑
SortAlbumsAsync把categoryId下的所有Album专辑排序(Guid categoryId, Guid[] sortedAlbumIds)改变的是序号属性
AddCategoryAsync创建一个Category类别
SortCategoriesAsync把所有Category类别排序(Guid[] sortedCategoryIds)改变的是序号属性
AddEpisodeAsync在指定albumId专辑Id下添加一张音频
SortEpisodesAsync把指定albumId专辑Id所有Episode音频排序(Guid albumId, Guid[] sortedEpisodeIds)改变的是序号属性

基础设施层

配置类

Guid主键不要建聚集索引

class AlbumConfig : IEntityTypeConfiguration<Album>
    {
        public void Configure(EntityTypeBuilder<Album> builder)
        {
            builder.ToTable("T_Albums");
            builder.HasKey(e => e.Id).IsClustered(false);//对于Guid主键,不要建聚集索引,否则插入性能很差
            builder.OwnsOneMultilingualString(e => e.Name);//OwnsOneMultilingualString封装的方法
            builder.HasIndex(e => new { e.CategoryId, e.IsDeleted });//为Id、软删除属性建立复合索引
        }
    }
class CategoryConfig : IEntityTypeConfiguration<Category>
    {
        public void Configure(EntityTypeBuilder<Category> builder)
        {
            builder.ToTable("T_Categories");
            builder.HasKey(e => e.Id).IsClustered(false);
            builder.OwnsOneMultilingualString(e => e.Name);
            builder.Property(e => e.CoverUrl).IsRequired(false).HasMaxLength(500).IsUnicode();
        }
    }
class EpisodeConfig : IEntityTypeConfiguration<Episode>
    {
        public void Configure(EntityTypeBuilder<Episode> builder)
        {
            builder.ToTable("T_Episodes");
            builder.HasKey(e => e.Id).IsClustered(false);//Guid类型不要聚集索引,否则会影响性能
            builder.HasIndex(e => new { e.AlbumId, e.IsDeleted });//索引不要忘了加上IsDeleted,否则会影响性能
            builder.OwnsOneMultilingualString(e => e.Name);
            //尽量用标准的、Provider无关的这些FluentAPI去配置,不要和数据库耦合
            //如果真的需要在IEntityTypeConfiguration中判断数据库类型
            //那么就定义一个接口提供DbContext属性,仿照ApplyConfigurationsFromAssembly写一个给IEntityTypeConfiguration
            //实现类注入DbContext,然后Dbcontext.Database.IsSqlServer(); 
            builder.Property(e => e.AudioUrl).HasMaxLength(1000).IsUnicode().IsRequired();
            builder.Property(e => e.Subtitle).HasMaxLength(int.MaxValue).IsUnicode().IsRequired();
            builder.Property(e => e.SubtitleType).HasMaxLength(10).IsUnicode(false).IsRequired();
        }
    }

仓储接口实现

GetMaxSeqOfCategoriesAsync:获取最大Category编号,考虑到最开始数据库中没有数据时的情况,对linq做调整

int? maxSeq = await dbCtx.Query<Category>().MaxAsync(c => (int?)c.SequenceNumber);
return maxSeq ?? 0;

完整代码

public class ListeningRepository : IListeningRepository
    {
        private readonly ListeningDbContext dbCtx;

        public ListeningRepository(ListeningDbContext dbCtx)
        {
            this.dbCtx = dbCtx;
        }

        public async Task<Category?> GetCategoryByIdAsync(Guid categoryId)
        {
            var album = await dbCtx.FindAsync<Category>(categoryId);
            return album;
        }

        public Task<Category[]> GetCategoriesAsync()
        {
            return dbCtx.Categories.OrderBy(e => e.SequenceNumber).ToArrayAsync();
        }

        public async Task<Album?> GetAlbumByIdAsync(Guid albumId)
        {
            var album = await dbCtx.FindAsync<Album>(albumId);
            return album;
        }

        public async Task<int> GetMaxSeqOfAlbumsAsync(Guid categoryId)
        {
            //MaxAsync(c => (int?)c.SequenceNumber) 这样可以处理一条数据都没有的问题
            int? maxSeq = await dbCtx.Query<Album>().Where(c => c.CategoryId == categoryId)
            .MaxAsync(c => (int?)c.SequenceNumber);
            return maxSeq ?? 0;
        }

        public Task<Album[]> GetAlbumsByCategoryIdAsync(Guid categoryId)
        {
            return dbCtx.Albums.OrderBy(e => e.SequenceNumber).Where(a => a.CategoryId == categoryId).ToArrayAsync();
        }

        public async Task<int> GetMaxSeqOfCategoriesAsync()
        {
            int? maxSeq = await dbCtx.Query<Category>().MaxAsync(c => (int?)c.SequenceNumber);
            return maxSeq ?? 0;
        }

        public async Task<Episode?> GetEpisodeByIdAsync(Guid episodeId)
        {
            return await dbCtx.Episodes.SingleOrDefaultAsync(e => e.Id == episodeId);
        }

        public async Task<int> GetMaxSeqOfEpisodesAsync(Guid albumId)
        {
            int? maxSeq = await dbCtx.Query<Episode>().Where(e => e.AlbumId == albumId)
                .MaxAsync(e => (int?)e.SequenceNumber);
            return maxSeq ?? 0;
        }

        public Task<Episode[]> GetEpisodesByAlbumIdAsync(Guid albumId)
        {
            return dbCtx.Episodes.OrderBy(e => e.SequenceNumber).Where(a => a.AlbumId == albumId).ToArrayAsync();
        }
    }

DbContext

public class ListeningDbContext : BaseDbContext
    {
        public DbSet<Category> Categories { get; private set; }//不要忘了写set,否则拿到的DbContext的Categories为null
        public DbSet<Album> Albums { get; private set; }
        public DbSet<Episode> Episodes { get; private set; }

        public ListeningDbContext(DbContextOptions<ListeningDbContext> options, IMediator mediator) : base(options, mediator)
        {
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
            modelBuilder.EnableSoftDeletionGlobalFilter();
        }
    }

模块自注册

class ModuleInitializer : IModuleInitializer
    {
        public void Initialize(IServiceCollection services)
        {
            services.AddScoped<IListeningRepository, ListeningRepository>();
        }
    }

前台应用层

1、前台应用层项目主要为前端页面提供分类、专辑、音频的数据。

2、因为前台页面被访问的频率很高,而且对于任何用户来讲,它们请求相同的听力资源获得的响应都是一样的,为了降低数据库的压力,我们在把从数据库中读取的数据放入了缓存。内存缓存更适合这里。

3、EpisodeController类的两个操作方法的返回值都是EpisodeVM类型,而非仓储返回的Episode类型。理由:

1)Episode类中有一些不希望被暴露给客户端的属性,比如Subtitle(听力字幕的文本)、CreationTime(创建时间)等。

2)我们希望把听力的字幕文本在服务器端解析,而不是在客户端再去解析。

3)在加载音频的列表的时候,不需要加载听力的字幕,以避免增加网络流量的消耗,而在加载某一个音频的时候才需要加载听力的字幕。

返回类ViewModel

ViewModels:AlbumVM、CategoryVM、EpisodeVM等,只给前端展示实体的部分属性

public record AlbumVM(Guid Id, MultilingualString Name, Guid CategoryId)
{
    public static AlbumVM? Create(Album? a)
    {
        if (a == null)
        {
            return null;
        }
        return new AlbumVM(a.Id, a.Name, a.CategoryId);
    }

    public static AlbumVM[] Create(Album[] items)
    {
        return items.Select(e => Create(e)!).ToArray();
    }
}
public record CategoryVM(Guid Id, MultilingualString Name, Uri CoverUrl)
{
    public static CategoryVM? Create(Category? e)
    {
        if (e == null)
        {
            return null;
        }
        return new CategoryVM(e.Id, e.Name, e.CoverUrl);
    }

    public static CategoryVM[] Create(Category[] items)
    {
        return items.Select(e => Create(e)!).ToArray();
    }
}
public record EpisodeVM(Guid Id, MultilingualString Name, Guid AlbumId, Uri AudioUrl, double DurationInSecond, IEnumerable<SentenceVM>? Sentences)
{
    public static EpisodeVM? Create(Episode? e, bool loadSubtitle)
    {
        if (e == null)
        {
            return null;
        }
        List<SentenceVM> sentenceVMs = new();
        if (loadSubtitle)
        {
            var sentences = e.ParseSubtitle();
            foreach (Sentence s in sentences)
            {
                SentenceVM vm = new SentenceVM(s.StartTime.TotalSeconds, s.EndTime.TotalSeconds, s.Value);
                sentenceVMs.Add(vm);
            }
        }
        return new EpisodeVM(e.Id, e.Name, e.AlbumId, e.AudioUrl, e.DurationInSecond, sentenceVMs);
    }

    public static EpisodeVM[] Create(Episode[] items, bool loadSubtitle)
    {
        return items.Select(e => Create(e, loadSubtitle)!).ToArray();
    }
}

值对象也来一个viewmodel,为了简化前端把TimeSpan格式字符串转换为毫秒数的麻烦,在服务器端直接把TimeSpan转换为double

public record SentenceVM(double StartTime, double EndTime, string Value);

控制器

专辑

[Route("[controller]/[action]")]
[ApiController]
public class AlbumController : ControllerBase
{
    private readonly IListeningRepository repository;
    private readonly IMemoryCacheHelper cacheHelper;
    public AlbumController(IListeningRepository repository, IMemoryCacheHelper cacheHelper)
    {
        this.repository = repository;
        this.cacheHelper = cacheHelper;
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<AlbumVM>> FindById([RequiredGuid] Guid id)
    {
        var album = await cacheHelper.GetOrCreateAsync($"AlbumController.FindById.{id}",
           async (e) => AlbumVM.Create(await repository.GetAlbumByIdAsync(id)));
        if (album == null)
        {
            return NotFound();
        }
        return album;
    }

    [HttpGet]
    [Route("{categoryId}")]
    public async Task<ActionResult<AlbumVM[]>> FindByCategoryId([RequiredGuid] Guid categoryId)
    {
        //写到单独的local函数的好处是避免回调中代码太复杂
        Task<Album[]> FindDataAsync()
        {
            return repository.GetAlbumsByCategoryIdAsync(categoryId);
        }
        var task = cacheHelper.GetOrCreateAsync($"AlbumController.FindByCategoryId.{categoryId}",
            async (e) => AlbumVM.Create(await FindDataAsync()));
        return await task;
    }
}

分类

[Route("[controller]/[action]")]
[ApiController]
//供后台用的增删改查接口不用缓存
public class CategoryController : ControllerBase
{
    //尽管这里的FindAll、FindById和Admin.WebAPI中类似,但是不应该复用。因为Admin.WebAPI中的操作没有缓存,
    //而Main.WebAPI中的则由缓存
    private readonly IListeningRepository repository;
    private readonly IMemoryCacheHelper cacheHelper;
    public CategoryController(IListeningRepository repository, IMemoryCacheHelper cacheHelper)
    {
        this.repository = repository;
        this.cacheHelper = cacheHelper;
    }

    [HttpGet]
    public async Task<ActionResult<CategoryVM[]>> FindAll()
    {
        Task<Category[]> FindData()
        {
            return repository.GetCategoriesAsync();
        }
        //用AOP来进行缓存控制看起来更优美(可以用国产的AspectCore或者Castle DynamicProxy),但是这样反而不灵活,因为缓存对于灵活性要求更高,所以用这种直接用ICacheHelper的不优美的方式更实用。
        var task = cacheHelper.GetOrCreateAsync($"CategoryController.FindAll",
            async (e) => CategoryVM.Create(await FindData()));
        return await task;
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<CategoryVM?>> FindById([RequiredGuid] Guid id)
    {
        var cat = await cacheHelper.GetOrCreateAsync($"CategoryController.FindById.{id}",
            async (e) => CategoryVM.Create(await repository.GetCategoryByIdAsync(id)));
        //返回ValueTask的需要await的一下
        if (cat == null)
        {
            return NotFound($"没有Id={id}的Category");
        }
        else
        {
            return cat;
        }
    }
}

音频

[Route("[controller]/[action]")]
[ApiController]
public class EpisodeController : ControllerBase
{
    private readonly IListeningRepository repository;
    private readonly IMemoryCacheHelper cacheHelper;

    public EpisodeController(IMemoryCacheHelper cacheHelper, IListeningRepository repository)
    {
        this.cacheHelper = cacheHelper;
        this.repository = repository;
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<EpisodeVM>> FindById([RequiredGuid] Guid id)
    {
        var episode = await cacheHelper.GetOrCreateAsync($"EpisodeController.FindById.{id}",
            async (e) => EpisodeVM.Create(await repository.GetEpisodeByIdAsync(id), true));
        if (episode == null)
        {
            return NotFound($"没有Id={id}的Episode");
        }
        return episode;
    }

    [HttpGet]
    [Route("{albumId}")]
    public async Task<ActionResult<EpisodeVM[]>> FindByAlbumId([RequiredGuid] Guid albumId)
    {
        Task<Episode[]> FindData()
        {
            return repository.GetEpisodesByAlbumIdAsync(albumId);
        }
        //加载Episode列表的,默认不加载Subtitle,这样降低流量大小
        var task = cacheHelper.GetOrCreateAsync($"EpisodeController.FindByAlbumId.{albumId}",
            async (e) => EpisodeVM.Create(await FindData(), false));
        return await task;
    }
}

服务与管道模型

Program.cs

UseDeveloperExceptionPage?这个中间件是干嘛的

var builder = WebApplication.CreateBuilder(args);
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
    LogFilePath = "e:/temp/Listening.Main.log",
    EventBusQueueName = "Listening.Main"
});
// Add services to the container.
//builder.Services.AddScoped<IListeningRepository, ListeningRepository>();
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "Listening.Main.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", "Listening.Main.WebAPI v1"));
}

app.UseZackDefault();
app.MapControllers();
app.Run();
VM详细

CategoryVM的设计:record类型

属性类型说明
Id
Name
CoverUrlUri封面图片地址
方法名说明返回类型参数补充
Create将Category实体转换为VM对象CategoryVM?(Category? e)静态方法
Create将Category[ ]实体数组转换为VM对象数组CategoryVM[](Category[] items)静态方法

AlbumVM的设计:record类型

属性名类型说明
IdGuid专辑Id
NameMultilingualString
CategoryIdGuid类别Id
方法名说明返回类型参数补充
Create创建一个专辑的VM对象AlbumVM?(Album? a)静态方法,VM对象用于返回给前端
Create创建一组专辑的VM对象数组AlbumVM[](Album[] items)静态方法

EpisodeVM

属性类型说明
Id
Name
AlbumId
AudioUrlUri音频路径
DurationInSeconddouble音频时长
SentencesIEnumerable?音频对应的句子
方法说明参数返回类型补充
Create(Episode? e, bool loadSubtitle)EpisodeVM?静态,loadSubtitle为true,则返回的EpisodeVM中的Sentences属性包含内容,否则为空
Create(Episode[] items, bool loadSubtitle)EpisodeVM[]静态,

SentenseVM的设计

属性类型说明
StartTime、EndTimedouble
Valuestring
控制器详细

AlbumController

方法名说明参数返回类型补充
FindByCategoryId通过类别Id查找专辑([RequiredGuid] Guid categoryId)Task<ActionResult<AlbumVM[]>>通过内存缓存提高加载数据的性能
FindById通过专辑Id查找专辑([RequiredGuid] Guid id)Task<ActionResult>

CategoryController

说明:尽管这里的FindAll、FindById和Admin.WebAPI中类似,但是不应该复用。因为Admin.WebAPI中的操作没有缓存,而Main.WebAPI中的则有缓存

方法名说明参数返回类型
FindAll查找所有分类()Task<ActionResult<CategoryVM[]>>
FindById通过分类Id查找分类([RequiredGuid] Guid id)Task<ActionResult<CategoryVM?>>

EpisodeController

方法名说明参数返回类型
FindById通过音频Id查找音频([RequiredGuid] Guid id)Task<ActionResult>
FindByAlbumId通过专辑Id查找音频([RequiredGuid] Guid albumId)Task<ActionResult<EpisodeVM[]>>

管理后台应用层

实体

请求及校验类

专辑

public record AlbumAddRequest(MultilingualString Name, Guid CategoryId);

//把校验规则写到单独的文件,也是DDD的一种原则
public class AlbumAddRequestValidator : AbstractValidator<AlbumAddRequest>
{
    public AlbumAddRequestValidator(ListeningDbContext dbCtx)
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
        ///验证CategoryId是否存在
        RuleFor(x => x.CategoryId).MustAsync((cId, ct) => dbCtx.Query<Category>().AnyAsync(c => c.Id == cId))
            .WithMessage(c => $"CategoryId={c.CategoryId}不存在");
    }
}
public class AlbumsSortRequest
{
    public Guid[] SortedAlbumIds { get; set; }
}

public class AlbumsSortRequestValidator : AbstractValidator<AlbumsSortRequest>
{
    public AlbumsSortRequestValidator()
    {
        RuleFor(r => r.SortedAlbumIds).NotNull().NotEmpty().NotContains(Guid.Empty)
            .NotDuplicated();
    }
}
public record AlbumUpdateRequest(MultilingualString Name);
public class AlbumUpdateRequestValidator : AbstractValidator<AlbumUpdateRequest>
{
    public AlbumUpdateRequestValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
    }
}

分类

public class CategoriesSortRequest
{
    /// <summary>
    /// 排序后的类别Id
    /// </summary>
    public Guid[] SortedCategoryIds { get; set; }
}

public class CategoriesSortRequestValidator : AbstractValidator<CategoriesSortRequest>
{
    public CategoriesSortRequestValidator()
    {
        RuleFor(r => r.SortedCategoryIds).NotNull().NotEmpty().NotContains(Guid.Empty)
            .NotDuplicated();
    }
}
//启用了<Nullable>enable</Nullable>,所以string ChineseName就是非可空,会自动校验
public record CategoryAddRequest(MultilingualString Name, Uri CoverUrl);
public class CategoryAddRequestValidator : AbstractValidator<CategoryAddRequest>
{
    public CategoryAddRequestValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
        RuleFor(x => x.CoverUrl).Length(5, 500);//CoverUrl允许为空
    }
}
//定义只是偶然和CategoryAddRequest一样,所以不应该复用它
public record CategoryUpdateRequest(MultilingualString Name, Uri CoverUrl);

public class CategoryUpdateRequestValidator : AbstractValidator<CategoryUpdateRequest>
{
    public CategoryUpdateRequestValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
        RuleFor(x => x.CoverUrl).Length(5, 500);
    }
}

音频

public record EpisodeAddRequest(MultilingualString Name, Guid AlbumId,
    Uri AudioUrl, double DurationInSecond, string Subtitle, string SubtitleType);

public class EpisodeAddRequestValidator : AbstractValidator<EpisodeAddRequest>
{
    public EpisodeAddRequestValidator(ListeningDbContext ctx)
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
        ///验证CategoryId是否存在
        RuleFor(x => x.AlbumId).MustAsync((cId, ct) => ctx.Query<Album>().AnyAsync(c => c.Id == cId))
            .WithMessage(c => $"AlbumId={c.AlbumId}不存在");
        RuleFor(x => x.AudioUrl).NotEmptyUri().Length(1, 1000);
        RuleFor(x => x.DurationInSecond).GreaterThan(0);
        RuleFor(x => x.SubtitleType).NotEmpty().Length(1, 10);
        RuleFor(x => x.Subtitle).NotEmpty().NotEmpty();
    }
}
public class EpisodesSortRequest
{
    public Guid[] SortedEpisodeIds { get; set; }
}

public class EpisodesSortRequestValidator : AbstractValidator<EpisodesSortRequest>
{
    public EpisodesSortRequestValidator()
    {
        RuleFor(r => r.SortedEpisodeIds).NotNull().NotEmpty().NotContains(Guid.Empty).NotDuplicated();
    }
}
/// <summary>
/// Episode的音频不能修改,否则会让代码复杂很多,主流视频网站也都是这样干的。
/// </summary>
/// <param name="Name"></param>
/// <param name="SubtitleType"></param>
/// <param name="Subtitle"></param>
public record EpisodeUpdateRequest(MultilingualString Name, string SubtitleType, string Subtitle);

public class EpisodeUpdateRequestValidator : AbstractValidator<EpisodeUpdateRequest>
{
    private ListeningDbContext ctx;
    public EpisodeUpdateRequestValidator(ListeningDbContext ctx)
    {
        this.ctx = ctx;
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Name.Chinese).NotNull().Length(1, 200);
        RuleFor(x => x.Name.English).NotNull().Length(1, 200);
        RuleFor(x => x.SubtitleType).NotEmpty().Length(1, 10);
        RuleFor(x => x.Subtitle).NotEmpty().NotEmpty(); ;
    }
}

响应类

public record EpisodeForFindByAlbumId(Guid Id, int SequenceNumber, MultilingualString Name, Uri AudioUrl, double DurationInSecond, DateTime CreationTime, bool IsVisible, string EncodingStatus);

因为这是后台系统,所以不在乎把 Episode全部内容返回给客户端的问题,以后如果开放给外部系统再定义ViewModel

控制器

CategoryController、AlbumController、EpisodeController分别提供了对分类、专辑、音频进行管理的接口。

管理员端的后台管理没有使用缓存

[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]
[UnitOfWork(typeof(ListeningDbContext))]
public class AlbumController : ControllerBase
{
    private readonly ListeningDbContext dbCtx;
    private IListeningRepository repository;
    private readonly ListeningDomainService domainService;
    public AlbumController(ListeningDbContext dbCtx, ListeningDomainService domainService, IListeningRepository repository)
    {
        this.dbCtx = dbCtx;
        this.domainService = domainService;
        this.repository = repository;
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<Album?>> FindById([RequiredGuid] Guid id)
    {
        var album = await repository.GetAlbumByIdAsync(id);
        return album;
    }

    [HttpGet]
    [Route("{categoryId}")]
    public Task<Album[]> FindByCategoryId([RequiredGuid] Guid categoryId)
    {
        return repository.GetAlbumsByCategoryIdAsync(categoryId);
    }

    [HttpPost]
    public async Task<ActionResult<Guid>> Add(AlbumAddRequest req)
    {
        Album album = await domainService.AddAlbumAsync(req.CategoryId, req.Name);
        dbCtx.Add(album);
        return album.Id;
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Update([RequiredGuid] Guid id, AlbumUpdateRequest request)
    {
        var album = await repository.GetAlbumByIdAsync(id);
        if (album == null)
        {
            return NotFound("id没找到");
        }
        album.ChangeName(request.Name);
        return Ok();
    }

    [HttpDelete]
    [Route("{id}")]
    public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
    {
        var album = await repository.GetAlbumByIdAsync(id);
        if (album == null)
        {
            //这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
            return NotFound($"没有Id={id}的Album");
        }
        album.SoftDelete();//软删除
        return Ok();
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Hide([RequiredGuid] Guid id)
    {
        var album = await repository.GetAlbumByIdAsync(id);
        if (album == null)
        {
            return NotFound($"没有Id={id}的Album");
        }
        album.Hide();
        return Ok();
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Show([RequiredGuid] Guid id)
    {
        var album = await repository.GetAlbumByIdAsync(id);
        if (album == null)
        {
            return NotFound($"没有Id={id}的Album");
        }
        album.Show();
        return Ok();
    }

    [HttpPut]
    [Route("{categoryId}")]
    public async Task<ActionResult> Sort([RequiredGuid] Guid categoryId, AlbumsSortRequest req)
    {
        await domainService.SortAlbumsAsync(categoryId, req.SortedAlbumIds);
        return Ok();
    }
}
[Route("[controller]/[action]")]
[Authorize(Roles = "Admin")]
[ApiController]
[UnitOfWork(typeof(ListeningDbContext))]
//供后台用的增删改查接口不用缓存
public class CategoryController : ControllerBase
{
    private IListeningRepository repository;
    private readonly ListeningDbContext dbContext;
    private readonly ListeningDomainService domainService;
    public  CategoryController(ListeningDbContext dbContext, ListeningDomainService domainService, IListeningRepository repository)
    {
        this.dbContext = dbContext;
        this.domainService = domainService;
        this.repository = repository;
    }


    [HttpGet]
    public Task<Category[]> FindAll()
    {
        return repository.GetCategoriesAsync();
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<Category?>> FindById([RequiredGuid] Guid id)
    {
        //返回ValueTask的需要await的一下
        var cat = await repository.GetCategoryByIdAsync(id);
        if (cat == null)
        {
            return NotFound($"没有Id={id}的Category");
        }
        else
        {
            return cat;
        }
    }

    [HttpPost]
    public async Task<ActionResult<Guid>> Add(CategoryAddRequest req)
    {
        var category = await domainService.AddCategoryAsync(req.Name, req.CoverUrl);
        dbContext.Add(category);
        return category.Id;
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Update([RequiredGuid] Guid id, CategoryUpdateRequest request)
    {
        var cat = await repository.GetCategoryByIdAsync(id);
        if (cat == null)
        {
            return NotFound("id不存在");
        }
        cat.ChangeName(request.Name);
        cat.ChangeCoverUrl(request.CoverUrl);
        return Ok();
    }

    [HttpDelete]
    [Route("{id}")]
    public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
    {
        var cat = await repository.GetCategoryByIdAsync(id);
        if (cat == null)
        {
            //这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
            return NotFound($"没有Id={id}的Category");
        }
        cat.SoftDelete();//软删除
        return Ok();
    }

    [HttpPut]
    public async Task<ActionResult> Sort(CategoriesSortRequest req)
    {
        await domainService.SortCategoriesAsync(req.SortedCategoryIds);
        return Ok();
    }
}

Add:如果是m4a格式文件,直接存入数据库。如果不是m4a格式,发布集成事件通知转码服务器进行转码

[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]
[UnitOfWork(typeof(ListeningDbContext))]
public class EpisodeController : ControllerBase
{
    private IListeningRepository repository;
    private readonly ListeningDbContext dbContext;
    private readonly EncodingEpisodeHelper encodingEpisodeHelper;
    private readonly IEventBus eventBus;
    private readonly ListeningDomainService domainService;
    public EpisodeController(ListeningDbContext dbContext,
            EncodingEpisodeHelper encodingEpisodeHelper,
            IEventBus eventBus, ListeningDomainService domainService, IListeningRepository repository)
    {
        this.dbContext = dbContext;
        this.encodingEpisodeHelper = encodingEpisodeHelper;
        this.eventBus = eventBus;
        this.domainService = domainService;
        this.repository = repository;
    }

    [HttpPost]
    public async Task<ActionResult<Guid>> Add(EpisodeAddRequest req)
    {
        //如果上传的是m4a,不用转码,直接存到数据库
        if (req.AudioUrl.ToString().EndsWith("m4a", StringComparison.OrdinalIgnoreCase))
        {
            Episode episode = await domainService.AddEpisodeAsync(req.Name, req.AlbumId,
                req.AudioUrl, req.DurationInSecond, req.SubtitleType, req.Subtitle);
            dbContext.Add(episode);
            return episode.Id;
        }
        else
        {
            //非m4a文件需要先转码,为了避免非法数据污染业务数据,增加业务逻辑麻烦,按照DDD的原则,不完整的Episode不能插入数据库
            //先临时插入Redis,转码完成再插入数据库
            Guid episodeId = Guid.NewGuid();
            EncodingEpisodeInfo encodingEpisode = new EncodingEpisodeInfo(episodeId, req.Name, req.AlbumId, req.DurationInSecond, req.Subtitle, req.SubtitleType, "Created");
            await encodingEpisodeHelper.AddEncodingEpisodeAsync(episodeId, encodingEpisode);

            //通知转码
            eventBus.Publish("MediaEncoding.Created", new { MediaId = episodeId, MediaUrl = req.AudioUrl, OutputFormat = "m4a", SourceSystem = "Listening" });//启动转码
            return episodeId;
        }
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Update([RequiredGuid] Guid id, EpisodeUpdateRequest request)
    {
        var episode = await repository.GetEpisodeByIdAsync(id);
        if (episode == null)
        {
            return NotFound("id没找到");
        }
        episode.ChangeName(request.Name);
        episode.ChangeSubtitle(request.SubtitleType, request.Subtitle);
        return Ok();
    }

    [HttpDelete]
    [Route("{id}")]
    public async Task<ActionResult> DeleteById([RequiredGuid] Guid id)
    {
        var album = await repository.GetEpisodeByIdAsync(id);
        if (album == null)
        {
            //这样做仍然是幂等的,因为“调用N次,确保服务器处于与第一次调用相同的状态。”与响应无关
            return NotFound($"没有Id={id}的Episode");
        }
        album.SoftDelete();//软删除
        return Ok();
    }

    [HttpGet]
    [Route("{id}")]
    public async Task<ActionResult<Episode>> FindById([RequiredGuid] Guid id)
    {
        //因为这是后台系统,所以不在乎把 Episode全部内容返回给客户端的问题,以后如果开放给外部系统再定义ViewModel
        var episode = await repository.GetEpisodeByIdAsync(id);
        if (episode == null)
        {
            return NotFound($"没有Id={id}的Episode");
        }
        return episode;
    }

    [HttpGet]
    [Route("{albumId}")]
    public Task<Episode[]> FindByAlbumId([RequiredGuid] Guid albumId)
    {
        return repository.GetEpisodesByAlbumIdAsync(albumId);
    }

    //获取albumId下所有的转码任务
    [HttpGet]
    [Route("{albumId}")]
    public async Task<ActionResult<EncodingEpisodeInfo[]>> FindEncodingEpisodesByAlbumId([RequiredGuid] Guid albumId)
    {
        List<EncodingEpisodeInfo> list = new List<EncodingEpisodeInfo>();
        var episodeIds = await encodingEpisodeHelper.GetEncodingEpisodeIdsAsync(albumId);
        foreach (Guid episodeId in episodeIds)
        {
            var encodingEpisode = await encodingEpisodeHelper.GetEncodingEpisodeAsync(episodeId);
            if (!encodingEpisode.Status.EqualsIgnoreCase("Completed"))//不显示已经完成的
            {
                list.Add(encodingEpisode);
            }
        }
        return list.ToArray();
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Hide([RequiredGuid] Guid id)
    {
        var episode = await repository.GetEpisodeByIdAsync(id);
        if (episode == null)
        {
            return NotFound($"没有Id={id}的Category");
        }
        episode.Hide();
        return Ok();
    }

    [HttpPut]
    [Route("{id}")]
    public async Task<ActionResult> Show([RequiredGuid] Guid id)
    {
        var episode = await repository.GetEpisodeByIdAsync(id);
        if (episode == null)
        {
            return NotFound($"没有Id={id}的Category");
        }
        episode.Show();
        return Ok();
    }

    [HttpPut]
    [Route("{albumId}")]
    public async Task<ActionResult> Sort([RequiredGuid] Guid albumId, EpisodesSortRequest req)
    {
        await domainService.SortEpisodesAsync(albumId, req.SortedEpisodeIds);
        return Ok();
    }
}

事件推送

1、为了能够让前端页面随时看到转码状态的变化,我们通过SignalR来把转码状态推送给前端。转码服务会在转码开始、转码失败、转码完成等事件出现的时候,发布名字分别为MediaEncoding.Started、MediaEncoding.Failed、MediaEncoding.Completed等集成事件,因此我们只要监听这些集成事件,然后把转码状态的变化推送到前端页面即可。

2、我们新建音频的时候,还要通知搜索服务收录新建的音频的原文。我们在新建音频的时候,会发布领域事件EpisodeCreatedEvent,但是领域事件只能被微服务内的代码监听到,而搜索服务属于一个独立的微服务,因此我们需要监听EpisodeCreatedEvent这个领域事件,然后再发布一个集成事件。

领域事件

事件消费者

EpisodeCreatedEventHandler:监听EpisodeCreatedEvent领域事件,把领域事件转发为集成事件,让其他微服务听到(搜索服务需要用到,实现搜索索引、记录日志等功能)

public class EpisodeCreatedEventHandler : INotificationHandler<EpisodeCreatedEvent>
{
    private readonly IEventBus eventBus;

    public EpisodeCreatedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public Task Handle(EpisodeCreatedEvent notification, CancellationToken cancellationToken)
    {
        //把领域事件转发为集成事件,让其他微服务听到

        //在领域事件处理中集中进行更新缓存等处理,而不是写到Controller中。因为项目中有可能不止一个地方操作领域对象,这样就就统一了操作。
        var episode = notification.Value;
        var sentences = episode.ParseSubtitle();
        eventBus.Publish("ListeningEpisode.Created", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });//发布集成事件,实现搜索索引、记录日志等功能
        return Task.CompletedTask;
    }
}

EpisodeDeletedEventHandler:通知搜索服务,这个东西被删了,不应该被搜到

public class EpisodeDeletedEventHandler : INotificationHandler<EpisodeDeletedEvent>
{
    private readonly IEventBus eventBus;

    public EpisodeDeletedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public Task Handle(EpisodeDeletedEvent notification, CancellationToken cancellationToken)
    {
        var id = notification.Id;
        eventBus.Publish("ListeningEpisode.Deleted", new { Id = id });
        return Task.CompletedTask;
    }
}

EpisodeUpdatedEventHandler:东西改了,跟着改变

public class EpisodeUpdatedEventHandler : INotificationHandler<EpisodeUpdatedEvent>
{
    private readonly IEventBus eventBus;

    public EpisodeUpdatedEventHandler(IEventBus eventBus)
    {
        this.eventBus = eventBus;
    }

    public Task Handle(EpisodeUpdatedEvent notification, CancellationToken cancellationToken)
    {
        var episode = notification.Value;
        //todo:如果音频地址改变,则重新转码
        //考虑不能修改音频地址,因为主流视频网站也都是这样做的。不过要考虑转码成功后修改AudioUrl的问题,应该的是:新增的时候不是真的新增,而是接入队列,因为这时候的Episode如果插入数据库是非法状态,如果允许非法数据插入数据库,就要对逻辑做复杂处理,所以转码之后才能插入数据库。
        if (episode.IsVisible)
        {
            var sentences = episode.ParseSubtitle();
            eventBus.Publish("ListeningEpisode.Updated", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });
        }
        else
        {
            //被隐藏
            eventBus.Publish("ListeningEpisode.Hidden", new { Id = episode.Id });
        }
        return Task.CompletedTask;
    }
}

集成事件

事件消费者

MediaEncodingStatusChangeIntegrationHandler:集成事件,转码服务器在转码开始、失败、已存在、成功等都会发出集成事件,这里收听转码服务发出的集成事件,把状态通过SignalR推送给客户端,从而显示“转码进度”

HandleDynamic方法:可能消息发给别人或发给我,如果不是发给我Listening的则跳过。如果开始了,在redis中把这条任务修改为开始转码了,并且通过Hub通知前端开始转码。如果转码完成,从redis中取出这条任务的详细信息,创建一个episdoe,插入到数据库,手动savechange,通知前端任务完成

//收听转码服务发出的集成事件
//把状态通过SignalR推送给客户端,从而显示“转码进度”
[EventName("MediaEncoding.Started")]
[EventName("MediaEncoding.Failed")]
[EventName("MediaEncoding.Duplicated")]
[EventName("MediaEncoding.Completed")]
class MediaEncodingStatusChangeIntegrationHandler : DynamicIntegrationEventHandler
{
    private readonly ListeningDbContext dbContext;
    private readonly IListeningRepository repository;
    private readonly EncodingEpisodeHelper encHelper;
    private readonly IHubContext<EpisodeEncodingStatusHub> hubContext;

    public MediaEncodingStatusChangeIntegrationHandler(ListeningDbContext dbContext,
        EncodingEpisodeHelper encHelper,
        IHubContext<EpisodeEncodingStatusHub> hubContext, IListeningRepository repository)
    {
        this.dbContext = dbContext;
        this.encHelper = encHelper;
        this.hubContext = hubContext;
        this.repository = repository;
    }

    public override async Task HandleDynamic(string eventName, dynamic eventData)
    {
        string sourceSystem = eventData.SourceSystem;
        if (sourceSystem != "Listening")//可能是别的系统的转码消息
        {
            return;
        }
        Guid id = Guid.Parse(eventData.Id);//EncodingItem的Id就是Episode 的Id

        switch (eventName)
        {
            case "MediaEncoding.Started":
                await encHelper.UpdateEpisodeStatusAsync(id, "Started");
                await hubContext.Clients.All.SendAsync("OnMediaEncodingStarted", id);//通知前端刷新
                break;
            case "MediaEncoding.Failed":
                await encHelper.UpdateEpisodeStatusAsync(id, "Failed");
                //todo: 这样做有问题,这样就会把消息发送给所有打开这个界面的人,应该用connectionId、userId等进行过滤,
                await hubContext.Clients.All.SendAsync("OnMediaEncodingFailed", id);
                break;
            case "MediaEncoding.Duplicated":
                await encHelper.UpdateEpisodeStatusAsync(id, "Completed");
                await hubContext.Clients.All.SendAsync("OnMediaEncodingCompleted", id);//通知前端刷新
                break;
            case "MediaEncoding.Completed":
                //转码完成,则从Redis中把暂存的Episode信息取出来,然后正式地插入Episode表中
                await encHelper.UpdateEpisodeStatusAsync(id, "Completed");
                Uri outputUrl = new Uri(eventData.OutputUrl);
                var encItem = await encHelper.GetEncodingEpisodeAsync(id);

                Guid albumId = encItem.AlbumId;
                int maxSeq = await repository.GetMaxSeqOfEpisodesAsync(albumId);
                /*
                Episode episode = Episode.Create(id, maxSeq.Value + 1, encodingEpisode.Name, albumId, outputUrl,
                    encodingEpisode.DurationInSecond, encodingEpisode.SubtitleType, encodingEpisode.Subtitle);*/
                var builder = new Episode.Builder();
                builder.Id(id).SequenceNumber(maxSeq + 1).Name(encItem.Name)
                    .AlbumId(albumId).AudioUrl(outputUrl)
                    .DurationInSecond(encItem.DurationInSecond)
                    .SubtitleType(encItem.SubtitleType).Subtitle(encItem.Subtitle);
                var episdoe = builder.Build();
                dbContext.Add(episdoe);
                await dbContext.SaveChangesAsync();
                await hubContext.Clients.All.SendAsync("OnMediaEncodingCompleted", id);//通知前端刷新
                break;
            default:
                throw new ArgumentOutOfRangeException(nameof(eventName));
        }
    }
}

ReIndexAllEventHandler:重建索引,把所有的Episode让搜索引擎重新收录一遍

    [EventName("SearchService.ReIndexAll")]
    //让搜索引擎服务器,重新收录所有的Episode
    public class ReIndexAllEventHandler : IIntegrationEventHandler
    {
        private readonly ListeningDbContext dbContext;
        private readonly IEventBus eventBus;

        public ReIndexAllEventHandler(ListeningDbContext dbContext, IEventBus eventBus)
        {
            this.dbContext = dbContext;
            this.eventBus = eventBus;
        }

        public Task Handle(string eventName, string eventData)
        {
            foreach (var episode in dbContext.Query<Episode>())
            {
                if (episode.IsVisible)
                {
                    var sentences = episode.ParseSubtitle();
                    eventBus.Publish("ListeningEpisode.Updated", new { Id = episode.Id, episode.Name, Sentences = sentences, episode.AlbumId, episode.Subtitle, episode.SubtitleType });
                }
            }
            return Task.CompletedTask;
        }
    }

集线器:通过signalR推送给前端,上传完成、准备转码、正在转码

public class EpisodeEncodingStatusHub : Hub
{
}

工具类,操作redis的

public class EncodingEpisodeHelper
{
    private readonly IConnectionMultiplexer redisConn;

    public EncodingEpisodeHelper(IConnectionMultiplexer redisConn)
    {
        this.redisConn = redisConn;
    }

    //一个kv对中保存这个albumId下所有的转码中的episodeId
    private static string GetKeyForEncodingEpisodeIdsOfAlbum(Guid albumId)
    {
        return $"Listening.EncodingEpisodeIdsOfAlbum.{albumId}";
    }
    private static string GetStatusKeyForEpisode(Guid episodeId)
    {
        string redisKey = $"Listening.EncodingEpisode.{episodeId}";
        return redisKey;
    }

    /// <summary>
    /// 增加待转码的任务的详细信息。先暂存到redis中
    /// </summary>
    /// <param name="albumId"></param>
    /// <param name="episode"></param>
    /// <returns></returns>
    public async Task AddEncodingEpisodeAsync(Guid episodeId, EncodingEpisodeInfo episode)
    {
        string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
        var db = redisConn.GetDatabase();
        await db.StringSetAsync(redisKeyForEpisode, episode.ToJsonString());//保存转码任务详细信息,供完成后插入数据库
        string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(episode.AlbumId);
        await db.SetAddAsync(keyForEncodingEpisodeIdsOfAlbum, episodeId.ToString());//保存这个album下所有待转码的episodeId
    }

    /// <summary>
    /// 获取这个albumId下所有转码任务
    /// </summary>
    /// <param name="albumId"></param>
    /// <returns></returns>
    public async Task<IEnumerable<Guid>> GetEncodingEpisodeIdsAsync(Guid albumId)
    {
        string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(albumId);
        var db = redisConn.GetDatabase();
        var values = await db.SetMembersAsync(keyForEncodingEpisodeIdsOfAlbum);
        return values.Select(v => Guid.Parse(v));
    }

    /// <summary>
    /// 删除一个Episode任务
    /// </summary>
    /// <param name="db"></param>
    /// <param name="episodeId"></param>
    /// <param name="albumId"></param>
    /// <returns></returns>
    public async Task RemoveEncodingEpisodeAsync(Guid episodeId, Guid albumId)
    {
        string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
        var db = redisConn.GetDatabase();
        await db.KeyDeleteAsync(redisKeyForEpisode);
        string keyForEncodingEpisodeIdsOfAlbum = GetKeyForEncodingEpisodeIdsOfAlbum(albumId);
        await db.SetRemoveAsync(keyForEncodingEpisodeIdsOfAlbum, episodeId.ToString());
    }

    /// <summary>
    /// 修改Episode的转码状态
    /// </summary>
    /// <param name="db"></param>
    /// <param name="episodeId"></param>
    /// <param name="status"></param>
    /// <returns></returns>
    public async Task UpdateEpisodeStatusAsync(Guid episodeId, string status)
    {
        string redisKeyForEpisode = GetStatusKeyForEpisode(episodeId);
        var db = redisConn.GetDatabase();
        string json = await db.StringGetAsync(redisKeyForEpisode);
        EncodingEpisodeInfo episode = json.ParseJson<EncodingEpisodeInfo>()!;
        episode = episode with { Status = status };
        await db.StringSetAsync(redisKeyForEpisode, episode.ToJsonString());
    }

    /// <summary>
    /// 获得Episode的转码状态
    /// </summary>
    /// <param name="db"></param>
    /// <param name="episodeId"></param>
    /// <returns></returns>
    public async Task<EncodingEpisodeInfo> GetEncodingEpisodeAsync(Guid episodeId)
    {
        string redisKey = GetStatusKeyForEpisode(episodeId);
        var db = redisConn.GetDatabase();
        string json = await db.StringGetAsync(redisKey);
        EncodingEpisodeInfo episode = json.ParseJson<EncodingEpisodeInfo>()!;
        return episode;
    }
}

转码任务实体:暂存到redis的Episode对象

public record EncodingEpisodeInfo(Guid Id, MultilingualString Name, Guid AlbumId, double DurationInSecond, string Subtitle, string SubtitleType, string Status);

服务及管道模型

服务及管道模型

注入工具类

builder.Services.AddScoped<EncodingEpisodeHelper>();

SignalR

builder.Services.AddSignalR();
//...
app.MapHub<EpisodeEncodingStatusHub>("/Hubs/EpisodeEncodingStatusHub");

完整代码

var builder = WebApplication.CreateBuilder(args);
builder.ConfigureDbConfiguration();
builder.ConfigureExtraServices(new InitializerOptions
{
    LogFilePath = "e:/temp/Listening.Admin.log",
    EventBusQueueName = "Listening.Admin"
});
builder.Services.AddScoped<EncodingEpisodeHelper>();
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new() { Title = "Listening.Admin.WebAPI", Version = "v1" });
});
builder.Services.AddSignalR();

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", "Listening.Admin.WebAPI v1"));
}
app.MapHub<EpisodeEncodingStatusHub>("/Hubs/EpisodeEncodingStatusHub");
app.UseZackDefault();
app.MapControllers();
app.Run();

Add的两个版本

1、为Episode实体增加一个bool类型的IsReady属性,用来表示这个音频是否可以被正常使用了。无论用户上传的音频是否为M4A格式,我们都会把它立即插入数据库中,只不过非M4A格式的音频的IsReady属性的值为false,只有转码完成后IsReady属性的值才被更新为true

缺点:程序中存在状态非法的Episode实体,代码中到处充斥着对IsReady属性的特殊处理,前台不能显示IsReady=0的,删除不能随便删(在转码中),修改时不能动IsReady=0的

DDD原则:一个实体不应该有处于不完整状态的时刻,这样就能减少程序中对于不完整状态进行处理的代码。因此一个没有完成转码的Episode对象就不应该存在于数据库中。

不应该有处于不完整状态的实体

2、一个没有完成转码的Episode对象就不应该存在于数据库中。如果用户上传的音频不是M4A格式,程序则不会立即把这个音频插入数据库,而是把用户提交的数据先暂存到Redis中,然后再启动音频转码,再转码完成后,程序再把Redis中暂存的音频数据保存到数据库中。

这样Episode对象一直处于完整状态,也就是数据库中的音频记录一定是可用的,代码就简单很多了。这就是DDD给系统设计带来的一个很大的好处

控制器详细

CategoryController

方法名说明参数返回类型补充
FindAll查找所有分类()Task<Category[]>
FindById通过专辑Id查找分类([RequiredGuid] Guid id)Task<ActionResult<Category?>>
Add添加一条分类(CategoryAddRequest req)Task<ActionResult>
Update更新一条分类([RequiredGuid] Guid id, CategoryUpdateRequest request)Task这里把专辑Id,更新的数据分开传入
DeleteById删除一条分类([RequiredGuid] Guid id)Task
Sort对分类排序(CategoriesSortRequest req)Task

AlbumController

方法名说明参数返回类型补充
FindById通过Id查找专辑FindById([RequiredGuid] Guid id)Task<ActionResult<Album?>>
FindByCategoryId通关分类Id查找专辑([RequiredGuid] Guid categoryId)Task<Album[]>
Add添加一条专辑(AlbumAddRequest req)Task<ActionResult>
Update更新一条专辑([RequiredGuid] Guid id, AlbumUpdateRequest request)Task
DeleteById删除一条专辑([RequiredGuid] Guid id)Task
Hide、Show显示、隐藏一条专辑([RequiredGuid] Guid id)Task
Sort对专辑排序([RequiredGuid] Guid categoryId, AlbumsSortRequest req)Task

EpisodeController

方法名说明参数返回类型
Add添加一条音频(EpisodeAddRequest req)Task<ActionResult>
Update更新一条音频([RequiredGuid] Guid id, EpisodeUpdateRequest request)Task
DeleteById删除一条音频([RequiredGuid] Guid id)Task
FindById通过Id查找一条音频([RequiredGuid] Guid id)Task<ActionResult>
FindByAlbumId通过专辑查找音频([RequiredGuid] Guid albumId)Task<Episode[]>
FindEncodingEpisodesByAlbumId获取albumId下所有的转码任务([RequiredGuid] Guid albumId)Task<ActionResult<EncodingEpisodeInfo[]>>
Hide、Show音频隐藏、显示([RequiredGuid] Guid id)Task
Sort音频排序([RequiredGuid] Guid albumId, EpisodesSortRequest req)Task