键盘敲烂,月薪过万作业不做,等于没学
当前系列: ASP.NET 修改讲义

文章发布

在之前标题正文的基础上,添加

分类

DDD套路,推荐“三部曲两头挤”:

  1. 引入/修改Entity,并进行migration
    public class Article : BaseEntity
    {
        public int? InCategoryId { get; set; }
        public Category InCategory { get; set; }
    public class Category : BaseEntity
    {
        //public IList<Article> Articles { get; set; } 可选
        public string Name { get; set; }
  2. 页面呈现,必然要考虑到Model(对应entity)
    <label>分类:</label>
    @Html.DropDownListFor(m=>m.SelectedCategoryId, new SelectList(Model.CategoryOptions,"Id", "Name"))
    public class NewModel
    {
        public int SelectedCategoryId { get; set; }
        public IList<CategoryModel> CategoryOptions { get; set; }
    }
    
    public class CategoryModel
    {
        public int Id { get; set; }
        public string Name { get; set; }
  3. Controller和Service配合:
    public ActionResult New()
    {
        NewModel model = new NewModel
        {
            //为测试UI效果临时实现
            CategoryOptions = new List<CategoryModel>
            {
                new CategoryModel { Id = 1, Name = "C#"}
            }
        };
        return View(model);
    之后引入Service(Mock)实现
    CategoryOptions = service.GetCategories()
    public class ArticleService : IArticleService
    {
        public IList<CategoryModel> GetCategories()
        {
            return new List<CategoryModel>
                {
                    new CategoryModel { Id = 1, Name = "C#"},
                    new CategoryModel { Id = 2, Name = "SQL"}

ProdService的实现,依赖于:

Repository和automapper

public IList<CategoryModel> GetCategories()
{
    IList<Category> categories = categoryRepository.GetBy(GetCurrentUser());
    return mapper.Map<IList<CategoryModel>>(categories);

不要忘了BaseService中配置映射:

cfg.CreateMap<Category, VM.Article.CategoryModel>();
注意这里只能使用Id进行比较:
public IList<Category> GetBy(User user)
{
    return dbSet.Where(c => c.Owner.Id == user.Id).ToList();

默认分类

演示:没有category选项

@想一想@:一开始用户没有分类怎么办?

  1. 用户能进入文章发布页面,就自动给一个默认分类
  2. 没有指定分类的文章就属于默认分类

我们选择实现方案1

if (categories.Count == 0)  //ToList()之后的categories不会为null
{
    Category defaultCategory = new Category { Name = "默认分类", Owner = current };
    categoryRepository.Save(defaultCategory);
    //不要忘了这一步,^_^
    categories.Add(defaultCategory);
}//else nothing

发布

在ArticleService的Publish()中带上分类。

现在可以使用AutoMapper了

Article article = mapper.Map<Article>(model);

Author无法自动映射,需要手动赋值:

article.Author = GetCurrentUser();

但注意Model和entity中属性名有差异,需要自定义映射:

cfg.CreateMap<NewModel, Article>()
    .ForMember(
        a => a.InCategoryId, 
        opt => opt.MapFrom(n => n.SelectedCategoryId))

复习:如果需要“纯面向对象”抛弃InCategoryId只保留InCategory的话,需要使用:

article.InCategory = categoryRepository.Load(model.SelectedCategoryId);

关键字

Keyword和Article多对多的entity声明和映射:略

View和Model:

@Html.TextBoxFor(m => m.Keywords)
public string Keywords { get; set; }
这里就出现了和entity中Article的Keywords不匹配的问题:
public class Article : BaseEntity
{
    public List<Keyword> Keywords { get; set; }

用户输入的是带有空格的字符串,但我们后台需要的是entity集合。所以,在ArticleService.Publish()中:

//@想一想@:为什么要Trim()
string[] keywords = model.Keywords.Trim().Split(' ');
for (int i = 0; i < keywords.Length; i++)
{
    //@想一想@:为什么要这个判断?
    if (!string.IsNullOrWhiteSpace(keywords[i]))
    {
        Keyword keyword = new Keyword { Name = keywords[i] };
        //复习:??运算符的作用
        article.Keywords = article.Keywords ?? new List<Keyword>();
        article.Keywords.Add(keyword);

检查重复

不要忘了:用户输入的关键字有可能是重复的,这时候我们不能再new了哟!

所以需要利用KeywordRepository检查

public Keyword GetByName(string name)
{
    return dbSet.Where(k => k.Name == name).SingleOrDefault();

但凡涉及到查询,就要想到索引!

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    modelBuilder.Entity<Keyword>()
        //不然默认nvarchar(max)不能生成索引
        .Property(k => k.Name).HasMaxLength(256);
    modelBuilder.Entity<Keyword>()
        .HasIndex(k => k.Name)
        .IsUnique()    //索引unique

最后的代码:

Keyword keyword = keywordRepository.GetByName(keywords[i]);
if (keyword == null)
{
    keyword = new Keyword { Name = keywords[i] };
}
@想一想@:这样就OK了么?


文章单页

标题和正文已经完成(略),关键在

其他内容

比如:Author、Category和Keywords

所以首先要把这些关联对象显式的加载出来:

public Article GetWhole(int id)    //命名太丑陋了,(*/ω\*)
{
    return dbSet.Where(a => a.Id == id)
        .Include(a => a.Author)
        .Include(a => a.InCategory)
        .Include(a => a.Keywords)
        .SingleOrDefault();

相应的,在SingleModel中添加关联Model:

public class SingleModel
{
    public _UserInfoModel Author { get; set; }
    public CategoryModel InCategory { get; set; }
    public IList<KeywordModel> Keywords { get; set; }

KeywordModel的映射需要AutoMapper配置:

cfg.CreateMap<Keyword, VM.Article.KeywordModel>();

最后,页面显示:

<a href="/User/@Model.Author.Id">@Model.Author.UserName</a>
<span>分类:</span>
<a href="/Article/Category/@Model.InCategory.Id">@Model.InCategory.Name</a>
<span>关键字:</span>
@foreach (var item in Model.Keywords)
{
    <a href="/Article/Keyword/@item.Id">@item.Name</a>
}


文章编辑

在文章单页添加一个编辑链接:/Article/Edit/{id}

@想一想@文章Id从何而来?

<a href="/Article/Edit/@ViewContext.RouteData.Values["id"]">修改</a>

页面呈现

@想一想@文章发布,和文章编辑,是否可共用:一个View/Model/Action?

除Action以外,其他都暂时是可以的,\(^o^)/

public ActionResult Edit(int id)
{
    return View("New");
}

[HttpPost]
public ActionResult Edit(NewModel model, int id)

进入编辑页面,首先要加载被编辑文章内容:

NewModel model = service.GetEdit(id);
public NewModel GetEdit(int id)
类似于SingleModel Get(int id),首先获取article(entity):
Article article = articleRepository.GetWhole(id);
但是,
  1. 最后的Map类型不同:
    NewModel model = mapper.Map<NewModel>(article);
    所以不要忘了添加Map:
    cfg.CreateMap<NewModel, Article>()
        .ReverseMap()    //简化代码
        //但ignore的配置还是不能自动双向
        .ForMember(n => n.Keywords, opt => opt.Ignore())
        ;
  2. Keywords需要转化:
    model.Keywords = string.Join(" ",
        article.Keywords.Select(k=>k.Name).ToArray()
        );

提交修改

只能用所有POST内容“覆盖”原article内容,不管某项内容(比如Title/Category)用户实际上有无修改……@想一想@:为什么只能这样?

在Post的Action方法里面直接调用:

service.Edit(model, id);

对比之前的Publish()方法:

  1. article要通过id从数据库加载,而不是automapper生成
    Article article = articleRepository.Find(id); 
  2. 使用Map()的“双参数”方法,“覆盖”article而不是新生成一个:
    mapper.Map(model, article);

关键字陷阱

抽象之前的关键字转化代码,封装成方法transferKeywords(),然后调用:

transferKeywords(model.Keywords, article);

演示:抛出重复异常,

Violation of PRIMARY KEY constraint 'PK_dbo.KeywordArticles'

原因:复习多对多关系更改

解决方案,推荐引入LazyLoad,然后“先清场,后添加”:

article.Keywords.Clear();
@想一想@这样改了之后,transferKeywords()还能不能被Publish()和Edit()重用?


消息&积分

我们使用“静态消息”和“积分明细记录”机制。

因为使用(现阶段版本的)EF往集合中添加元素,会导致所有已有集合元素被加载,而User的Message和Credit可能会超级多,所以我们演示在Service层实现:

PS:以上不懂就复习消息和积分的架构

生成

  • 消息
    Message message = new Message
    {
        Body = $"{user.UserName}使用了你的邀请码完成注册",
        Receiver = invitedBy,
        Kind = Kind.InvitedBy
    };
    messageRepository.Add(message);
  • 积分
    Credit credit = new Credit
    {
        Owner = invitedBy,
        Amount = 10,
        Comment = $"{user.UserName}使用了你的邀请码完成注册",
        Balance = creditRepository.GetLatest()?.Balance ?? 0 + 10
    };
    creditRepository.Add(credit);

注意:

  1. 因为经常使用,messageRepository和creditRepository(包括userRepository)可以放在BaseService中,并在其构造函数中实例化:
    protected UserRepository userRepository;
    protected MessageRepository messageRepository;
    protected CreditRepository creditRepository;
    
    public BaseService()
    {
        userRepository = new UserRepository(currentDbContext);
    
  2. 积分的Balance需要查询
    public Credit GetLatest()
    {
        return dbSet.OrderByDescending(x => x.Id).FirstOrDefault();

消息列表页

演示:列表页

//能不能foreach,为什么?
for (int i = 0; i < Model.Items.Count; i++)
{
    int fontWeight = Model.Items[i].ReadTime.HasValue ? 400 : 800;
    <div style="font-weight:@fontWeight;" >
        @*这个HiddenFor干嘛的?*@
        @Html.HiddenFor(m => Model.Items[i].Id)
        @Html.CheckBoxFor(m => Model.Items[i].Selected)
        @Model.Items[i].Body
#体会#:利用fontWeight控制样式……

已读和删除

在POST的Action中:

public ActionResult Index(IndexModel model)
{
    foreach (var item in model.Items)
    {
        if (item.Selected)  //checkbox勾选
        {
            //item.Id:hidden input传回
            messageService.Read(item.Id);

两种方案:

  1. 通过formaction复习,演示指向不同的Action:
    <button type="submit" formaction="/Message/Read">已读</button>
    <button type="submit" formaction="/Message/Delete">删除</button>
    对应:
    public ActionResult Read(IndexModel model){
    public ActionResult Delete(IndexModel model){
    缺点:不利于代码重用
  2. 使用input标签,
    <input type="submit" name="submit" value="已读" />
    <input type="submit" name="submit" value="删除" />
    在后台通过value区分究竟是哪一个按钮被点中:
    string btnValue = Request.Form["submit"];
    于是就可以重用代码:
    if (btnValue == "已读")//这种字符串的比较总是怪怪的,^_^
    {
        messageService.Read(item.Id);
    }
    else if (btnValue == "删除")
    {
        //因为“静态”消息,所以不用标记删除,就可以直接删除
        messageService.Delete(item.Id);
    }
    else
    {
        //严谨做法:不是nothing #体会#
        throw new Exception("前台传回非标准数据……");
    }

另:Delete()的实现可以不用Find(),稍稍优化:(复习)

public void Delete(int it)
{
    T entity = new T { Id = it };
    dbSet.Attach(entity);
    dbSet.Remove(entity);

HtmlTemplates

为了给message的body套上HTML标签(听不懂还是复习,^_^)

在Global中添加HtmlTemplates文件夹,以及MessageTemplate,为“被作为邀请人注册”使用的方法Register.Invited()

namespace Global.HtmlTemplates
{
    public static class MessageTemplate
    {
        public static class Register
        {
            public static string Invited(int id, string name)
            {
                return $@"<a href='/User/{id}'>{name}</a> 使用了你的邀请码完成注册";

其中,生成链接的功能还可以被进一步封装到SharedTemplate.Href:

public static string Common(string url, string text)
{
    return $@"<a href='{url}'>{text}</a>";
所以,最终的代码:
string url = $"/User/{id}";
return $@"{SharedTemplate.Href.Common(url, name)}使用了你的邀请码完成注册";

在生成message的时候调用:

Body = Global.HtmlTemplates.MessageTemplate
    .Register.Invited(user.Id, user.UserName),


DbFactory

复习:为什么需要

我们可以直接创建一个控制台项目DbFactory,添加对

  • Repositories
  • EntityFramework
  • 以及(此后)对Entities/Global……

的引用。

建库建表

我们首先需要拿到Repository中的DbContext,在Program.Main()中生成数据库和表结构:

SqlContext context = new SqlContext();
//因为LocalDB经常因为“被占用”无法删除,可以考虑总是手动删库
//context.Database.Delete(); 
context.Database.Create();

不要忘了在当前项目的App.config复制粘贴17bang的connectionStrings

生成数据

准备好和Entity(或Controller)相对应的类,比如OnUser/OnArticle,每个类都有一个create()方法,里面依次生成一个一个的注册用户/文章等。

可以把生成并持久化entity的方法抽象出来,比如:

internal static void create()
{
    fg = register("飞哥", null);
    atai = register("atai", fg);
}

private static User register(string name, User invitedBy)
{
    User user = new User
    {
        UserName = name,
        Password = password,
        InvitedBy = invitedBy
    };

说明:

  1. fg和atai都被暴露出来,
    internal static User fg, atai;
    以供其他factory类使用,比如OnArticle时就需要作者
    internal class OnArticle
    {
        internal static void create()
        {
            aspnet = publish("ASP.NET实战", "", OnUser.fg);
  2. password统一使用1234,便于记忆,^_^
    private static readonly string password = "1234";

同一个DbContext

专门建一个类:

internal class DbContextProvider {
    private static readonly SqlContext context = new SqlContext();

    internal static SqlContext GetContext()
    {
        return context;

确保所有的操作在同一个DbContext中完成!(复习:避免DbContext冲突)

DbContextProvider.GetContext().Users.Add(user);
DbContextProvider.GetContext().SaveChanges();

//也可以:new UserRepository(DbContextProvider.GetContext()).Add(fg);

重用业务逻辑

千万不要仅仅把DbFactory当做一个只存储entity本身的工具!

DbFactory中的数据应该是和entity的业务逻辑一致的,比如用户是通过Register生成的,Register()的过程中还:

  1. 随机生成了邀请码,所以还要调用Register()方法
  2. 生成了相关的Message和Credit,但这里因为使用EF的性能考虑,没有在Register()方法中生成,怎么办呢?

一种解决方案是为User添加一个方法:

public Message GetMessageOnRegister()
{
    return new Message
    {
        Body = Global.HtmlTemplates.MessageTemplate
            .Register.Invited(Id, UserName),
        Receiver = InvitedBy,
        Kind = Kind.InvitedByr

然后,这个方法返回的Message就既可以在Service,也可以在DbFactory中使用!\(^o^)/

Message message = user.GetMessageOnRegister();
if (message.Receiver != null)    //或者:if(message!=null)
{
    DbContextProvider.GetContext().Messages.Add(message);
    DbContextProvider.GetContext().SaveChanges();

演示:成功生成User和Message……


作业

完成以下功能:

  1. 发布/修改/显示文章的时候,包含进广告内容
  2. 发布求助的时候用户会设置悬赏,于是系统会“冻结”用户悬赏数额的帮帮币
    • 被冻结的帮帮币不可再用,即应减少balance结余
    • 后面如果求助
  3. 帮帮豆(BBean):每次用户登录,系统会检查该用户的上次帮帮豆发放时间;如果已超过24小时,就为其重新发放一次,数量随机,之前的剩余清零。

使用dbFactory创建数据,并完成:

  1. 帮帮币明细列表(注册/作为邀请人/文章被评论和赞等获得帮帮币,发布文章/使用广告等扣帮帮币)页面,加载时合计计算,检查结余有无错误:
    • 正确无误:图标打√
    • 如有错误:图标为×
  2. 显示各种文章(或者求助/意见建议)列表:某作者的、某分类的;能过滤、能排序……
  3. 彻底实现分页功能:
    • 计算出一共应有多少页(比如32页)
    • 但一次最多只能显示10页,怎么进入第11页?
    并将其封装重用。
学习笔记
源栈学历
大多数人,都低估了编程学习的难度,而高估了自己的学习能力和毅力。

作业

觉得很 ,不要忘记分享哟!

任何问题,都可以直接加 QQ群:273534701

在当前系列 ASP.NET 中继续学习:

多快好省!前端后端,线上线下,名师精讲

  • 先学习,后付费;
  • 不满意,不要钱。
  • 编程培训班,我就选源栈

更多了解 加:

QQ群:273534701

答疑解惑,远程debug……

B站 源栈-小九 的直播间

写代码要保持微笑 (๑•̀ㅂ•́)و✧

公众号:源栈一起帮

二维码