• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

开发现代ASP.NET应用程序新思想、新技术、新架构——更好更快的开发现代ASP.NET应用程 ...

原作者: [db:作者] 来自: [db:来源] 收藏 邀请
 

  今天在@张善友@田园里的蟋蟀的博客看到微软“.Net社区虚拟大会”dotnetConf2015的信息,感谢他们的真诚付出!真希望自已也能为中国的.NET社区贡献绵薄之力。


  上周星期天开通了博客并发布了第一篇文章《新思想、新技术、新架构——更好更快的开发现代ASP.NET应用程序》,汇集了一些比较流行的技术和开源项目,也把自己的程序架构、部分代码风格、前端表现简单做了一些展示,引起了近100位朋友的评论。特别感谢@田园里的蟋蟀、@深蓝医生、@郭明锋、@疯狂的提子、@jimcsharp、@以吾之名等给我建议和指导的朋友,也感谢那些给我支持和鼓励的朋友。还有对我提出批评的朋友,说我的面试题的内容不当,也很感谢他们让我更注意言辞,但并不会影响我对面试者基础知识的重视程度。


  上周发布那篇文章主要是因为这段时间在招聘过程中发现几乎所有面试者对基础知识和新技术都知之甚少,有过几年工作经验的程序员也几乎只会单一模式的CURD,没有明显的技术特长,所以我想分享一些自己认为比较好的思想、技术、架构模式,引起更多ASP.NET程序员的思考和讨论。


  其实,上周星期天是花了大半天写一篇博客,在发出来之前删掉了一大半内容(一些讲述我自己心路历程的内容),因为我在博客园是一个新人,在没有对别人提供价值帮助之前也许没人关心我是谁。那天由于时间太晚了,很多想写的内容都没有写出来,发布的时候仅贴了一些图片,后来在评论中写了很多内容,并修改了原文正文,补充分享了一些非常好的开源项目。希望之前看过的朋友可以再回去看看,给个链接:http://www.cnblogs.com/mienreal/p/4340864.html


  之前的一个项目是做的微信公众平台的第三方平台,提供微网站自主建站、会员卡、微商城、外卖预订等几十项功能。在项目初期,我仅担任产品总监负责产品设计,后来因为没有强大的前端团队,不得不亲自实现微官网的可视化设计器的前端。再后来公司让我接管了开发部(全是JAVA开发人员),跟开发团队有了更直接的配合。我发现他们普遍代码质量不高,几乎不懂得运用设计模式和最佳实践。每新增或修改一点功能,都要将全部代码进行编译和发布,会影响正在登录使用的用户,而且有时候一个经验不足的程序员修改的一点东西会让整个平台不能正常启动。跟几个高级工程师多次沟通,希望他们学习新技术新思想,运用成熟的最佳实践来提高代码质量;希望他们了解领域驱动设计用于会员卡等业务较复杂的模块;希望他们能了解OSGI实现模块化开发和部署,但因为经验能力和积极性等原因,这些愿望都没有实现。后来在新项目(开发代号Fami)中,我选择了.NET技术平台,并组建新的开发团队来进行这个项目。现在项目才刚完成基础框架和项目规范。


  下面把这个项目的架构思想和功能特性再分享一下。希望对正在设计架构的朋友有一个参考作用。本项目是Saas模式的在线产品,需实现多租户模式;有多个功能模块,且上线时间有先有后,需实现模块化开发。

 

本项目总体分为两个部分:一个基础框架组件,一个Fami解决方案。

 

 

基础框架组件的功能:
1、基础框架组件独立、通用,可用于多个不同项目。类似于daxnet的Apworks框架。
2、对项目实现模块化开发提供了支持,每个模块有独立的EF DbContext,可单独指定数据库。
3、对DDD的技术实现进行了封装,让项目以极精简的代码,专注于业务领域。
4、多租户支持,每个租户的数据自动隔离,业务模块开发者不需要手动操作TenantId。
5、集成ASP.NET Identity,实现登录认证、功能权限授权&验证、角色和用户管理。
6、集成Log4Net,实现日志记录。
7、集成AutoMapper,实现Dto类与实体类的双向自动转换。
8、实现UnitOfWork模式,为应用层和仓储层的(会写数据库的)方法自动实现数据库事务。
9、可通过ApplicationService的方法自动建立相应的WebApi方法,ajax可直接调用,不需要写ApiController和Action。
10、调用ApplicationService的方法时,自动验证权限和参数有效性(用相应的Attribute标注)。
11、继承自FullAuditedEntity基类的领域实体,会自动实现软删除(在数据库中用IsDeleted字段进行标注)。
12、实现一系列扩展方法,简化编码。

 

Fami项目解决方案结构图:

 
模块化结构图   WEB项目结构图

 

每个模块是一个独立的类库项目,有独立的DbContext(如上面左图中的WechatMpDbContext.cs),可单独指定不同的数据库链接,以实现按功能模块分库。

每个模块有自己权限提供类(WechatMpAuthorizationProvider.cs)、设置提供类(WechatMpSettingProvider.cs)、仓储基类(WechatMpRepository.cs)。

模块的展现层代码(MVC文件)放在WEB项目的Areas下,有自己单独的路由注册类文件(如上面右图中的WechatMpAreaRegistration.cs)。

 

MVC的Controller只有极少的代码,用于返回列表页的View、表单页面的View和Model,新建、编辑、删除等操作无需写Action方法,直接由前端的ajax调用Application层的相应Service方法(运行时,动态代理自动生成ApiController及相应方法)。

拿一个最最简单的图文素材功能举例说明:

 

Domain层的Article实体类:

 1 namespace Fami.WechatMp
 2 {
 3     public class Article : AuditedEntityAndTenant
 4     {
 5         [MaxLength(50)]
 6         public string Title { get; set; }
 7 
 8         [MaxLength(512)]
 9         public string PicUrl { get; set; }
10 
11         [MaxLength(1000)]
12         public string Interoduction { get; set; }
13 
14         [MaxLength(512)]
15         public string LinkUrl { get; set; }
16 
17         [MaxLength(512)]
18         public string OriginalUrl { get; set; }
19 
20         public string Content { get; set; }
21 
22         [ForeignKey("ArticleCategoryId")]
23         public ArticleCategory ArticleCategory { get; set; }
24 
25         public Guid ArticleCategoryId { get; set; }
26     }
27 }

 

Application层的ArticleDto类(用于WEB前端表单与Application层之间传值):

 1 namespace Fami.WechatMp
 2 {
 3     [AutoMap(typeof(Article))]
 4     public class ArticleDto : EntityDto, IValidate
 5     {
 6         [Required]
 7         [MaxLength(50)]
 8         public string Title { get; set; }
 9 
10         [MaxLength(512)]
11         public string PicUrl { get; set; }
12 
13         [MaxLength(1000)]
14         public string Interoduction { get; set; }
15 
16         [MaxLength(512)]
17         public string LinkUrl { get; set; }
18 
19         [MaxLength(512)]
20         public string OriginalUrl { get; set; }
21 
22         public string Content { get; set; }
23 
24         public Guid ArticleCategoryId { get; set; }
25     }
26 }

 

Application层的ArticleItem类(用于WEB前端查询列表的显示):

 1 namespace Fami.WechatMp
 2 {
 3     [AutoMapFrom(typeof(Article))]
 4     public class ArticleItem : EntityDto
 5     {
 6         public string Title { get; set; }
 7 
 8         public string PicUrl { get; set; }
 9 
10         public string LinkUrl { get; set; }
11 
12         public string OriginalUrl { get; set; }
13 
14         public string ArticleCategoryCategoryName { get; set; } //会自动读取ArticleCategory的CategoryName属性
15 
16         public DateTime CreationTime { get; set; }
17     }
18 }

 

Application层的IArticleAppService接口:

 1 namespace Fami.WechatMp
 2 {
 3     public interface IArticleAppService : IApplicationService
 4     {
 5         /// <summary>
 6         /// 获取素材分类列表(下拉框)
 7         /// </summary>
 8         /// <returns></returns>
 9         Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories();
10 
11         #region 素材查询和更新操作
12         /// <summary>
13         /// 创建素材信息
14         /// </summary>
15         /// <param name="model"></param>
16         /// <returns></returns>
17         Task<ArticleDto> CreateArticle(ArticleDto model);
18 
19         /// <summary>
20         /// 更新素材信息
21         /// </summary>
22         /// <param name="model"></param>
23         /// <returns></returns>
24         Task UpdateArticle(ArticleDto model);
25 
26         /// <summary>
27         /// 批量删除素材信息
28         /// </summary>
29         /// <param name="input"></param>
30         /// <returns></returns>
31         Task BatchDeleteArticle(IEnumerable<Guid> idList);
32 
33         /// <summary>
34         /// 获取指定的素材信息
35         /// </summary>
36         /// <param name="id"></param>
37         /// <returns></returns>
38         Task<ArticleDto> GetArticle(Guid id);
39 
40         /// <summary>
41         /// 查询素材列表信息(Table)
42         /// </summary>
43         /// <param name="input"></param>
44         /// <returns></returns>
45         Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input);
46 
47         #endregion
48     }
49 }

 

Application层的ArticleAppService实现类:

 1 namespace Fami.WechatMp
 2 {
 3     public class ArticleAppService : FamiAppServiceBase, IArticleAppService
 4     {
 5         private readonly IWechatMpRepository<ArticleCategory> _articleCategoryRepository;
 6         private readonly IWechatMpRepository<Article> _articleRepository;
 7         private readonly IArticlePolicy _articlePolicy;
 8 
 9         public ArticleAppService(
10             IWechatMpRepository<ArticleCategory> articleCategoryRepository,
11             IWechatMpRepository<Article> articleRepository,
12             IArticlePolicy articlePolicy
13             )
14         {
15             _articleCategoryRepository = articleCategoryRepository;
16             _articleRepository = articleRepository;
17             _articlePolicy = articlePolicy;
18         }
19 
20         public async Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories()
21         {
22             var query = _articleCategoryRepository.GetAll().OrderBy(item => item.DisplayOrder);
23             return await query.Query().To<ArticleCategoryDto>().Take(100).ToListAsync();
24         }
25 
26         public async Task<ArticleDto> CreateArticle(ArticleDto model)
27         {
28             if (await _articlePolicy.IsExistsArticleByName(model.Title))
29             {
30                 throw new UserFriendlyException(L("NameIsExists"));
31             }
32             var entity = await _articleRepository.InsertAsync(model.MapTo<Article>());
33             return entity.MapTo<ArticleDto>();
34         }
35 
36         public async Task UpdateArticle(ArticleDto model)
37         {
38             if (await _articlePolicy.IsExistsArticleByName(model.Title, model.Id))
39             {
40                 throw new UserFriendlyException(L("NameIsExists"));
41             }
42             var entity = await _articleRepository.GetAsync(model.Id);
43             await _articleRepository.UpdateAsync(model.MapTo(entity));
44         }
45 
46         public async Task BatchDeleteArticle(IEnumerable<Guid> idList)
47         {
48             if (await _articlePolicy.IsExistsByArticleAutoreplySetting(idList.ToList()))
49             {
50                 throw new UserFriendlyException(L("AutoreplyArticleIsExists"));
51             }
52             await _articleRepository.BatchDeleteAsync(idList);
53         }
54 
55         public async Task<ArticleDto> GetArticle(Guid id)
56         {
57             var entity = await _articleRepository.GetAsync(id);
58             return entity.MapTo<ArticleDto>();
59         }
60 
61         /// <summary>
62         /// 根据查询条件,返回文章列表数据
63         /// </summary>
64         /// <param name="input">查询条件</param>
65         /// <returns></returns>
66         public async Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input)
67         {
68             var query = _articleRepository.GetAll()
69                 .WhereIf(input.ArticleCategoryId.HasValue, m => m.ArticleCategoryId == input.ArticleCategoryId.Value)
70                 .WhereIf(!input.Keywords.IsNullOrWhiteSpace(), m => m.Title.Contains(input.Keywords));
71 
72             var result = await query.Query(input).ToAsync<ArticleItem>();
73             return result;
74         }
75     }
76 }

 

ArticleController.cs代码如下:

 1 namespace Fami.Mc.Web.Controllers
 2 {
 3     public class ArticleController : FamiControllerBase
 4     {
 5         private readonly IArticleAppService _articleAppService;
 6 
 7         public ArticleController(IArticleAppService articleAppService)
 8         {
 9             _articleAppService = articleAppService;
10         }
11 
12         public async Task<ActionResult> Index()
13         {
14             ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories();
15             return View();
16         }
17 
18         public async Task<ActionResult> Edit(Guid? id)
19         {
20             ArticleDto model;
21             if (!id.HasValue)  //新建
22             {
23                 model = new ArticleDto();
24                 ViewBag.ActionName = "createArticle";
25             }
26             else  //编辑
27             {
28                 model = await _articleAppService.GetArticle(id.Value);
29                 ViewBag.ActionName = "updateArticle";
30             }
31             ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories();
32             return View(model);
33         }
34     }
35 }

 

Views/Article/Index.cshtml代码(列表页):

 1 <div class="page-content">
 2     <div class="page-header">
 3         <div class="page-title">文章管理</div>
 4         <!-- 过滤条件start -->
 5         <div >
 6             <div class="clearfix" style="margin-right:30px;">
 7                 <div class="clearfix pull-left" style="line-height: 30px; margin: 3px 5px; ">
 8                     <div class="pull-left">分类:</div>
 9                     <div class="pull-left">
10                         @Html.DropDownList("ArticleCategoryId", new SelectList(ViewBag.ArticleCategoryDtos, "Id", "CategoryName"), "", new { @class = "form-control w180"})
11                     </div>
12                 </div>
13                 <div class="clearfix pull-left" style="line-height: 30px; margin: 3px 5px;">
14                     <div class="pull-left">搜索:</div>
15                     <div class="input-group input-group-sm w130">
16                         <input class="form-control pull-left" placeholder="文章标题" filterfield="Keywords" name="Keywords" type="text">
17                         <span class="input-group-btn">
18                             <button class="btn btn-default btnSearch" type="button"><i class="icon-search2 fs14"></i></button>
19                         </span>
20                     </div>
21                 </div>
22             </div>
23         </div>
24         <!-- 过滤条件end -->
25     </div>
26 
27     <!-- 列表上的功能按钮放在这里 -->
28     <div class="buttons-panel">
29         <button ></i>新增文章</button>
30         <button ></i>编辑</button>
31         <button ></i>删除 </button>
32         <button ></i>刷新 </button>
33     </div>
34     <table ></table>
35 </div>
36 @section js{
37     @Scripts.Render("~/js/datatables")
38     <script src="~/Areas/WechatMp/js/article.js"></script>
39 }

 

article.js代码:

 1 var listColumns = [
 2         listCheckboxColumn,
 3         { "name": "id", "data": "id", title: "ID", "sortable": false, "visible": false },
 4         { "name": "title", "data": "title", title: "名称" },
 5         {
 6             "name": "picUrl", "data": "picUrl", title: "图片", "width": "100", "sortable": false,
 7             "render": function (data) { return '<img src="' + abp.resourcePath + data + '" style="width:60px;"/>';}
 8         },
 9         { "name": "articleCategoryCategoryName", "data": "articleCategoryCategoryName", title: "所属分类" },
10         { "name": "linkUrl", "data": "linkUrl", title: "外链地址" },
11         { "name": "originalUrl", "data": "originalUrl", title: "原文地址" },
12         { "name": "creationTime", "data": "creationTime", title: "创建时间", "width": "180" }
13 ];
14 
15 $(function () {
16     abp.grid.init({
17             order: [[abp.grid.getColIndex("creationTime"), "desc"]],
18             filterbar: "#filterbar",//过滤区域selector
19             table: "#mytable",//table selector
20             ajax: abp.grid.ajaxLoadEx({
21                 "url": abp.appPath + "api/wechatmp/article/getArticleList",
22             }),
23             columns: listColumns
24         });
25 
26     //新增
27     $("#btnNew").click(function () {
28         abp.dialog({
29             width: "900px",
30             title: "新增文章",
31             href: abp.appPath + 'WechatMp/Article/Edit',
32             callback: abp.grid.reloadList
33         });
34     });
35 
36     //编辑
37     $("#btnEdit").on('click', function () {
38         var row = abp.grid.getSelectedOneRowData();
39         if (!row) return;
40         abp.dialog({
41             width: "900px",
42             title: "编辑分类",
43             href: abp.appPath + 'WechatMp/Article/Edit/' + row.id,
44             callback: abp.grid.reloadList
45         });
46     });
47 
48     //删除
49     $("#btnDeletes").on('click', function () {
50         var idList = abp.grid.getSelectedIdList();
51         if (idList.length == 0) return;
52 
53         abp.confirm(abp.utils.formatString("您确认要删除选中的{0}行吗?", idList.length), function (result) {
54             if (!result) return; //取消
55             abp.ajax({
56                 url: abp.appPath + 'api/wechatmp/article/batchDeleteArticle',
57                 data: idList
58             }).done(function (ret) {
59                 abp.success("删除成功");
60                 abp.grid.reloadList();
61             });
62         });
63     });
64 })

 

界面截图:

 

在进行这个列表查询时,客户端ajax直接调用ArticleAppService的GetArticleList方法,看下浏览器请求:

会根据文章分类的下拉选项,自动生成ArticleCategoryId的查询过滤参数。

 

 

服务端执行GetArticleList方法,自动把客户端ajax提交的数据组装成input参数(GetArticleListInput类指定的结构),然后根据过滤条件进行查询:

 1         /// <summary>
 2         /// 根据查询条件,返回文章列表数据
 3         /// </summary>
 4         /// <param name="input">查询条件</param>
 5         /// <returns></returns>
 6         public async Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input)
 7         {
 8             var query = _articleRepository.GetAll()
 9                 .WhereIf(input.ArticleCategoryId.HasValue, m => m.ArticleCategoryId == input.ArticleCategoryId.Value)
10                 .WhereIf(!input.Keywords.IsNullOrWhiteSpace(), m => m.Title.Contains(input.Keywords));
11 
12             var result = await query.Query(input).ToAsync<ArticleItem>();
13             return result;
14         }

 这个例子中仅过滤了ArticleCategoryId,没有输入标题中的关键字

 

EF自动生成的SQL如下,只查ArticleItem类指定的字段,会自动关键文章分类表查取分类名称,会自动根据当前登录用户的TenantId(租户Id)来过滤。

并且取总记录数和取指定页数据的两步操作,仅会生成一条Sql语句在SqlServer中执行:

 

 1 exec sp_executesql N'-- Query #1
 2 
 3 SELECT 
 4     [GroupBy1].[A1] AS [C1]
 5     FROM ( SELECT 
 6         COUNT(1) AS [A1]
 7         FROM [dbo].[WechatMp_Article] AS [Extent1]
 8         WHERE (cast(''e5f2aea7-1423-4708-8162-7d029f5966d1'' as uniqueidentifier) = [Extent1].[TenantId]) AND ([Extent1].[ArticleCategoryId] = @f0_p__linq__0)
 9     )  AS [GroupBy1];
10 
11 -- Query #2
12 
13 SELECT TOP (10) 
14     [Project1].[C1] AS [C1], 
15     [Project1].[Title] AS [Title], 
16     [Project1].[PicUrl] AS [PicUrl], 
17     [Project1].[LinkUrl] AS [LinkUrl], 
18     [Project1].[OriginalUrl] AS [OriginalUrl], 
19     [Project1].[CategoryName] AS [CategoryName], 
20     [Project1].[CreationTime] AS [CreationTime], 
21     [Project1].[Id] AS [Id]
22     FROM ( SELECT 
23         [Extent1].[Id] AS [Id], 
24         [Extent1].[Title] AS [Title], 
25         [Extent1].[PicUrl] AS [PicUrl], 
26         [Extent1].[LinkUrl] AS [LinkUrl], 
27         [Extent1].[OriginalUrl] AS [OriginalUrl], 
28         [Extent1].[CreationTime] AS [CreationTime], 
29         [Extent2].[CategoryName] AS [CategoryName], 
30         1 AS [C1]
31         FROM  [dbo].[WechatMp_Article] AS [Extent1]
32         INNER JOIN [dbo].[WechatMp_ArticleCategory] AS [Extent2] ON [Extent1].[ArticleCategoryId] = [Extent2].[Id]
33         WHERE (cast(''e5f2aea7-1423-4708-8162-7d029f5966d1'' as uniqueidentifier) = [Extent1].[TenantId]) AND ([Extent1].[ArticleCategoryId] = @f1_p__linq__0)
34     )  AS [Project1]
35     ORDER BY [Project1].[CreationTime] DESC;
36 ',N'@f0_p__linq__0 uniqueidentifier,@f1_p__linq__0 uniqueidentifier',@f0_p__linq__0='05506DBD-A0CB-449D-82F9-A462014C4440',@f1_p__linq__0='05506DBD-A0CB-449D-82F9-A462014C4440'

 

 

  

由于这个功能实在太简单,没有使用到领域服务、领域事件,这里可能只能说明一件事件:没有复杂业务逻辑的功能使用此DDD框架,并不会增加代码量,反而我认为这样的代码量差不多已经少到极致了。

 

真没想到今晚又搞到这么晚,一篇文章写了5个小时了,写文章实在太慢了!有兴趣的朋友还是互动讨论吧。

 

以后再对框架的每一种机制进行详细说明。

 

—————————————————————————————————————————————————————————————— 

2015-3-23 13:10补充:

下面贴一下框架层Repository基类的接口,为了显示简洁,我发到这里的代码把注释全去掉了,从方法名称和参数很容易知道他们的作用,

除返回IQueryable<TEntity>接口的GetAll()方法,其他都有同步和异步两个版本。

  1 public interface IRepository<TEntity, TPrimaryKey> : IRepository where TEntity : class, IEntity<TPrimaryKey>
  2 {
  3     IQueryable<TEntity> GetAll();
  4 
  5     List<TEntity> GetAllList();
  6 
  7     Task<List<TEntity>> GetAllListAsync();
  8 
  9     List<TEntity> GetAllList(Expression<Func<TEntity, bool>> predicate);
 10 
 11     Task<List<TEntity>> GetAllListAsync(Expression<Func<TEntity, bool>> predicate);
 12 
 13     TEntity Get(TPrimaryKey id);
 14 
 15     Task<TEntity> GetAsync(TPrimaryKey id);
 16 
 17     TEntity Single(Expression<Func<TEntity, bool>> predicate);
 18 
 19     Task<TEntity> SingleAsync(Expression<Func<TEntity, bool>> predicate);
 20 
 21     TEntity FirstOrDefault(TPrimaryKey id);
 22 
 23     Task<TEntity> FirstOrDefaultAsync(TPrimaryKey id);
 24 
 25     TEntity FirstOrDefault(Expression<Func<TEntity, bool>> predicate);
 26 
 27     Task<TEntity> FirstOrDefaultAsync(Expression<Func<TEntity, bool>> predicate);
 28 
 29     TEntity Insert(TEntity entity);
 30 
 31     Task<TEntity> InsertAsync(TEntity entity);
 32 
 33     TPrimaryKey InsertAndGetId(TEntity entity);
 34 
 35     Task<TPrimaryKey> InsertAndGetIdAsync(TEntity entity);
 36 
 37     TEntity InsertOrUpdate(TEntity entity);
 38 
 39     Task<TEntity> InsertOrUpdateAsync(TEntity entity);
 40 
 41     TPrimaryKey InsertOrUpdateAndGetId(TEntity entity);
 42 
 43     Task<TPrimaryKey> InsertOrUpdateAndGetIdAsync(TEntity entity);
 44 
 45     TEntity Update(TEntity entity);
 46 
 47     Task<TEntity> UpdateAsync(TEntity entity);
 48 
 49     TEntity Update(TPrimaryKey id, Action<TEntity> updateAction);
 50 
 51     Task<TEntity> UpdateAsync(TPrimaryKey id, Func<TEntity, Task> updateAction);
 52 
 53     int BatchUpdate(Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, TEntity>> updateExpression);
 54 
 55     Task<int> BatchUpdateAsync(Expression<Func<TEntity, bool>> predicate, Expression<Func<TEntity, TEntity>> updateExpression);
 56 
 57     void BatchUpdateDisplayOrder(IEnumerable<TPrimaryKey> idList);  
 58 
 59     Task BatchUpdateDisplayOrderAsync(IEnumerable<TPrimaryKey> idList);
 60 
 61     void Delete(TEntity entity);
 62 
 63     Task DeleteAsync(TEntity entity);
 64 
 65     void Delete(TPrimaryKey id);
 66 
 67     Task DeleteAsync(TPrimaryKey id);
 68 
 69     void Delete(Expression<Func<TEntity, bool>> predicate);
 70 
 71     Task DeleteAsync(Expression<Func<TEntity, bool>> predicate);
 72 
 73     void Delete(IEnumerable<TPrimaryKey> idList);
 74 
 75     Task DeleteAsync(IEnumerable<TPrimaryKey> idList);
 76 
 77     void BatchDelete(Expression<Func<TEntity, bool>> predicate);
 78 
 79     Task BatchDeleteAsync(Expression<Func<TEntity, bool>> predicate);
 80 
 81     void BatchDelete(IEnumerable<TPrimaryKey> idList);
 82 
 83     Task BatchDeleteAsync(IEnumerable<TPrimaryKey> idList);
 84 
 85     int Count();
 86 
 87     Task<int> CountAsync();
 88 
 89     int Count(Expression<Func<TEntity, bool>> predicate);
 90 
 91     Task<int> CountAsync(Expression<Func<TEntity, bool>> predicate);
 92 
 93     long LongCount();
 94 
 95     Task<long> LongCountAsync();
 96 
 97     long LongCount(Expression<Func<TEntity, bool>> predicate);
 98 
 99     Task<long> LongCountAsync(Expression<Func<TEntity, bool>> predicate);
100 }

 

可能只有BatchUpdateDisplayOrder方法可能不太容易理解,我单独说明一下:这个是列表页面对表格行手动上下拖动排序后,根据idList传入的Id及顺序,更新DisplayOrder字段

(只有在数据量不大,不需要分页的情况下,才允许使用这种方式手动排序)

 

 

 

--------------------------------------------------------------------------------------------------

2015-3-23 15:40补充 回复@何镇汐 多租户机制的自动实现

自动实现两方面的操作:

1、新建实体时自动从当前用户的session中取出所属的租户标识(TenantId) 给实体的TenantId赋值

2、查询数据时自动根据当前用户的TenantId过滤

 

先说第1个,自动赋值的实现方式:

拿本文上面的创建文章例子来说明

ArticleAppService的CreateArticle方式主要代码如下:

        public async Task<ArticleDto> CreateArticle(ArticleDto model)
        {
            var entity = await _articleRepository.InsertAsync(model.MapTo<Article>());
            return entity.MapTo<ArticleDto>();
        }

CreateArticle方法中“model.MapTo<Article>()” 会自动创建Article实体类的实例(在基类的构造函数中自动生成Guid类型的Id),并将表单控件输入的值(Dto类的属性)赋值给新建的实体类,然后调用仓储基类的Insert方法,这时并没有提交到数据库。因为框架会自动给CreateArticle方法应用UnitOfWork并开启数据库事务,当CreateArticle方法顺利执行完毕(没有抛出异常),会应用框架基类DbContext中的SaveChangesAsync方法,做一些自动赋值和事件触发后再调用base.SaveChangesAsync

请看代码:

 1         public override int SaveChanges()
 2         {
 3             ApplyAbpConcepts();
 4             return base.SaveChanges();
 5         }
 6 
 7         public override Task<int> SaveChangesAsync(CancellationToken cancellationToken)
 8         {
 9             ApplyAbpConcepts();
10             return base.SaveChangesAsync(cancellationToken);
11         }
12 
13         private void ApplyAbpConcepts()
14         {
15             foreach (var entry in ChangeTracker.Entries())
16             {
17                 switch (entry.State)
18                 {
19                     case EntityState.Added:
20                         SetCreationAuditProperties(entry);
21                         EntityEventHelper.TriggerEntityCreatingEvent(entry.Entity);  // <-- 请看这里
22                         EntityEventHelper.TriggerEntityCreatedEvent(entry.Entity);
23                         break;
24                     case EntityState.Modified:
25                         if (entry.Entity is ISoftDelete && entry.Entity.As<ISoftDelete>().IsDeleted)
26                         {
27                             HandleSoftDelete(entry);
28                             EntityEventHelper.TriggerEntityDeletedEvent(entry.Entity);
29                         }
30                         else
31                         {
32                             SetModificationAuditProperties(entry);
33                             EntityEventHelper.TriggerEntityUpdatedEvent(entry.Entity);
34                         }
35                         break;
36                     case EntityState.Deleted:
37                         HandleSoftDelete(entry);
38                         EntityEventHelper.TriggerEntityDeletedEvent(entry.Entity);
39                         break;
40                 }
41             }
42         }
43 
44         private void SetCreationAuditProperties(DbEntityEntry entry)
45         {
46             if (entry.Entity is IHasCreationTime)
47             {
48                 entry.Cast<IHasCreationTime>().Entity.CreationTime = DateTime.Now;
49             }
50 
51             if (entry.Entity is ICreationAudited)
52             {
53                 entry.Cast<ICreationAudited>().Entity.CreatorUserId = AbpSession.UserId;
54             }
55         }
56 
57         private void SetModificationAuditProperties(DbEntityEntry entry)
58         {
59             if (entry.Entity is IModificationAudited)
60             {
61                 var auditedEntry = entry.Cast<IModificationAudited>();
62 
63                 auditedEntry.Entity.LastModificationTime = DateTime.Now;
64                 auditedEntry.Entity.LastModifierUserId = AbpSession.UserId;
65             }
66         }
67 
68         private void HandleSoftDelete(DbEntityEntry entry)
69         {
70             if (entry.Entity is ISoftDelete)
71             {
72                 var softDeleteEntry = entry.Cast<ISoftDelete>();
73 
74                 softDeleteEntry.State = EntityState.Unchanged;
75                 softDeleteEntry.Entity.IsDeleted = true;
76 
77                 if (entry.Entity is IDeletionAudited)
78                 {
79                     var deletionAuditedEntry = entry.Cast<IDeletionAudited>();
80                     deletionAuditedEntry.Entity.DeletionTime = DateTime.Now;
81                     deletionAuditedEntry.Entity.DeleterUserId = AbpSession.UserId;
82                 }
83             }
84         }

 

然后再看EntityEventHelper.TriggerEntityCreatingEvent的实现代码:

1         public void TriggerEntityCreatingEvent(object entity)
2         {
3             var entityType = entity.GetType();
4             var eventType = typeof(EntityCreatingEventData<>).MakeGenericType(entityType);
5             var eventData = (IEventData)Activator.CreateInstance(eventType, new[] { entity });
6             EventBus.Trigger(eventType, eventData);
7         }

就是通过框架的EventBus触发了一个事件,然后在Fami项目里捕获这个事件:

 1     public class EntityCreatingEventHandler : IEventHandler<EntityCreatingEventData<Entity>>, ITransientDependency
 2     {
 3         private readonly IAbpSession _session;
 4 
 5         public EntityCreatingEventHandler(IAbpSession session)
 6         {
 7             _session = session;
 8         }
 9 
10         public void HandleEvent(EntityCreatingEventData<Entity> eventData)
11         {
12             autoFillRelationId(eventData.Entity);
13         }
14 
15         //新增实体时,自动填入关联的TenantId、xxxxId
16         private void autoFillRelationId(Entity entity)
17         {
18             if (entity is IMustHaveTenant)
19 { 20 ((IMustHaveTenant)entity).TenantId = _session.GetTenantId(); 21 } 22 ...... //这里把其他代码删掉了 23 } 24 25 }

这样就自动赋值了,当然前提是这个实体实现了IMustHaveTenant接口,我写了相应基类自动实现了这个接口。

1     public interface IMustHaveTenant
2     {
3         Guid TenantId { get; set; }
4     }
1     public abstract class AuditedEntityAndTenant : AuditedEntity, IMustHaveTenant, IFilterByTenant
2     {
3         [Index]
4         public virtual Guid TenantId { get; set; }
5     }

 

再说第2个,查询时自动实现TenantId的过滤:

自动过滤的实现方式很简单,在Fami项目的仓储基类FamiRepository.cs中重写了基础框架组件仓储基类EfRepositoryBase的GetAll()方法

 1 namespace Fami.Core
 2 {
 3     public class FamiRepository<TDbContext, TEntity, TPrimaryKey> : EfRepositoryBase<TDbContext, TEntity, TPrimaryKey>
 4         where TEntity : class, IEntity<TPrimaryKey>
 5         where TDbContext : DbContext
 6     {
 7         public FamiRepository(IDbContextProvider<TDbContext> dbContextProvider)
 8             : base(dbContextProvider)
 9         {
10         }
11 
12         //整个解决方案共用的仓储方法写在这里
13 
14 
15 
16         public override IQueryable<TEntity> GetAll()
17         {
18             var query = Table as IQueryable<TEntity>;
19             //LYM 每个查询前都根据接口添加过滤条件
20             if (typeof(IFilterByTenant).IsAssignableFrom(typeof(TEntity)))
21             {
22                 query = query.Where(createEqualityExpression<TEntity, Guid>("TenantId", _session.GetTenantId()));
23             }
24 
25             return query;
26         }
27 
28         private static Expression<Func<TEntity, bool>> createEqualityExpression<TEntity, TType>(string keyName, TType value) where TEntity : class//, IEntity
29         {
30             var lambdaParam = Expression.Parameter(typeof(TEntity), "entity");
31 
32             var lambdaBody = Expression.Equal(
33                 Expression.PropertyOrField(lambdaParam, keyName),
34                 Expression.Constant(value, typeof(TType))
35                 );
36 
37             return Expression.Lambda<Func<TEntity, bool>>(lambdaBody, lambdaParam);
38         }
39 
40     }
41 
42     public class FamiRepository<TEntity, TPrimaryKey> : EfRepositoryBase<CoreDbContext, TEntity, TPrimaryKey>
43         where TEntity : class, IEntity<TPrimaryKey>
44     {
45         public FamiRepository(IDbContextProvider<CoreDbContext> dbContextProvider)
46             : base(dbContextProvider)
47         {
48         }
49 
50     }
51 
52     public class FamiRepository<TEntity> : FamiRepository<CoreDbContext, TEntity, Guid>, IFamiRepository<TEntity>
53         where TEntity : class, IEntity
54     {
55         public FamiRepository(IDbContextProvider<CoreDbContext> dbContextProvider)
56             : base(dbContextProvider)
57         {
58         }
59 
60     }
61 }

 

以后在使用FamiRepository<TEntity>类型调用任何查询方法,都会根据TenantId进行过滤。这种方法暂时只能对聚合根实体过滤TenantId。

您可以看看上面文章查询列表的AppService方法和生成的SQL语句。

 

 
 
Bootstrap

  今天在@张善友@田园里的蟋蟀的博客看到微软“.Net社区虚拟大会”dotnetConf2015的信息,感谢他们的真诚付出!真希望自已也能为中国的.NET社区贡献绵薄之力。


  上周星期天开通了博客并发布了第一篇文章《新思想、新技术、新架构——更好更快的开发现代ASP.NET应用程序》,汇集了一些比较流行的技术和开源项目,也把自己的程序架构、部分代码风格、前端表现简单做了一些展示,引起了近100位朋友的评论。特别感谢@田园里的蟋蟀、@深蓝医生、@郭明锋、@疯狂的提子、@jimcsharp、@以吾之名等给我建议和指导的朋友,也感谢那些给我支持和鼓励的朋友。还有对我提出批评的朋友,说我的面试题的内容不当,也很感谢他们让我更注意言辞,但并不会影响我对面试者基础知识的重视程度。


  上周发布那篇文章主要是因为这段时间在招聘过程中发现几乎所有面试者对基础知识和新技术都知之甚少,有过几年工作经验的程序员也几乎只会单一模式的CURD,没有明显的技术特长,所以我想分享一些自己认为比较好的思想、技术、架构模式,引起更多ASP.NET程序员的思考和讨论。


  其实,上周星期天是花了大半天写一篇博客,在发出来之前删掉了一大半内容(一些讲述我自己心路历程的内容),因为我在博客园是一个新人,在没有对别人提供价值帮助之前也许没人关心我是谁。那天由于时间太晚了,很多想写的内容都没有写出来,发布的时候仅贴了一些图片,后来在评论中写了很多内容,并修改了原文正文,补充分享了一些非常好的开源项目。希望之前看过的朋友可以再回去看看,给个链接:http://www.cnblogs.com/mienreal/p/4340864.html


  之前的一个项目是做的微信公众平台的第三方平台,提供微网站自主建站、会员卡、微商城、外卖预订等几十项功能。在项目初期,我仅担任产品总监负责产品设计,后来因为没有强大的前端团队,不得不亲自实现微官网的可视化设计器的前端。再后来公司让我接管了开发部(全是JAVA开发人员),跟开发团队有了更直接的配合。我发现他们普遍代码质量不高,几乎不懂得运用设计模式和最佳实践。每新增或修改一点功能,都要将全部代码进行编译和发布,会影响正在登录使用的用户,而且有时候一个经验不足的程序员修改的一点东西会让整个平台不能正常启动。跟几个高级工程师多次沟通,希望他们学习新技术新思想,运用成熟的最佳实践来提高代码质量;希望他们了解领域驱动设计用于会员卡等业务较复杂的模块;希望他们能了解OSGI实现模块化开发和部署,但因为经验能力和积极性等原因,这些愿望都没有实现。后来在新项目(开发代号Fami)中,我选择了.NET技术平台,并组建新的开发团队来进行这个项目。现在项目才刚完成基础框架和项目规范。


  下面把这个项目的架构思想和功能特性再分享一下。希望对正在设计架构的朋友有一个参考作用。本项目是Saas模式的在线产品,需实现多租户模式;有多个功能模块,且上线时间有先有后,需实现模块化开发。

 

本项目总体分为两个部分:一个基础框架组件,一个Fami解决方案。

 

 

基础框架组件的功能:
1、基础框架组件独立、通用,可用于多个不同项目。类似于daxnet的Apworks框架。
2、对项目实现模块化开发提供了支持,每个模块有独立的EF DbContext,可单独指定数据库。
3、对DDD的技术实现进行了封装,让项目以极精简的代码,专注于业务领域。
4、多租户支持,每个租户的数据自动隔离,业务模块开发者不需要手动操作TenantId。
5、集成ASP.NET Identity,实现登录认证、功能权限授权&验证、角色和用户管理。
6、集成Log4Net,实现日志记录。
7、集成AutoMapper,实现Dto类与实体类的双向自动转换。
8、实现UnitOfWork模式,为应用层和仓储层的(会写数据库的)方法自动实现数据库事务。
9、可通过ApplicationService的方法自动建立相应的WebApi方法,ajax可直接调用,不需要写ApiController和Action。
10、调用ApplicationService的方法时,自动验证权限和参数有效性(用相应的Attribute标注)。
11、继承自FullAuditedEntity基类的领域实体,会自动实现软删除(在数据库中用IsDeleted字段进行标注)。
12、实现一系列扩展方法,简化编码。

 

Fami项目解决方案结构图:

 
模块化结构图   WEB项目结构图

 

每个模块是一个独立的类库项目,有独立的DbContext(如上面左图中的WechatMpDbContext.cs),可单独指定不同的数据库链接,以实现按功能模块分库。

每个模块有自己权限提供类(WechatMpAuthorizationProvider.cs)、设置提供类(WechatMpSettingProvider.cs)、仓储基类(WechatMpRepository.cs)。

模块的展现层代码(MVC文件)放在WEB项目的Areas下,有自己单独的路由注册类文件(如上面右图中的WechatMpAreaRegistration.cs)。

 

MVC的Controller只有极少的代码,用于返回列表页的View、表单页面的View和Model,新建、编辑、删除等操作无需写Action方法,直接由前端的ajax调用Application层的相应Service方法(运行时,动态代理自动生成ApiController及相应方法)。

拿一个最最简单的图文素材功能举例说明:

 

Domain层的Article实体类:

 1 namespace Fami.WechatMp
 2 {
 3     public class Article : AuditedEntityAndTenant
 4     {
 5         [MaxLength(50)]
 6         public string Title { get; set; }
 7 
 8         [MaxLength(512)]
 9         public string PicUrl { get; set; }
10 
11         [MaxLength(1000)]
12         public string Interoduction { get; set; }
13 
14         [MaxLength(512)]
15         public string LinkUrl { get; set; }
16 
17         [MaxLength(512)]
18         public string OriginalUrl { get; set; }
19 
20         public string Content { get; set; }
21 
22         [ForeignKey("ArticleCategoryId")]
23         public ArticleCategory ArticleCategory { get; set; }
24 
25         public Guid ArticleCategoryId { get; set; }
26     }
27 }

 

Application层的ArticleDto类(用于WEB前端表单与Application层之间传值):

 1 namespace Fami.WechatMp
 2 {
 3     [AutoMap(typeof(Article))]
 4     public class ArticleDto : EntityDto, IValidate
 5     {
 6         [Required]
 7         [MaxLength(50)]
 8         public string Title { get; set; }
 9 
10         [MaxLength(512)]
11         public string PicUrl { get; set; }
12 
13         [MaxLength(1000)]
14         public string Interoduction { get; set; }
15 
16         [MaxLength(512)]
17         public string LinkUrl { get; set; }
18 
19         [MaxLength(512)]
20         public string OriginalUrl { get; set; }
21 
22         public string Content { get; set; }
23 
24         public Guid ArticleCategoryId { get; set; }
25     }
26 }

 

Application层的ArticleItem类(用于WEB前端查询列表的显示):

 1 namespace Fami.WechatMp
 2 {
 3     [AutoMapFrom(typeof(Article))]
 4     public class ArticleItem : EntityDto
 5     {
 6         public string Title { get; set; }
 7 
 8         public string PicUrl { get; set; }
 9 
10         public string LinkUrl { get; set; }
11 
12         public string OriginalUrl { get; set; }
13 
14         public string ArticleCategoryCategoryName { get; set; } //会自动读取ArticleCategory的CategoryName属性
15 
16         public DateTime CreationTime { get; set; }
17     }
18 }

 

Application层的IArticleAppService接口:

 1 namespace Fami.WechatMp
 2 {
 3     public interface IArticleAppService : IApplicationService
 4     {
 5         /// <summary>
 6         /// 获取素材分类列表(下拉框)
 7         /// </summary>
 8         /// <returns></returns>
 9         Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories();
10 
11         #region 素材查询和更新操作
12         /// <summary>
13         /// 创建素材信息
14         /// </summary>
15         /// <param name="model"></param>
16         /// <returns></returns>
17         Task<ArticleDto> CreateArticle(ArticleDto model);
18 
19         /// <summary>
20         /// 更新素材信息
21         /// </summary>
22         /// <param name="model"></param>
23         /// <returns></returns>
24         Task UpdateArticle(ArticleDto model);
25 
26         /// <summary>
27         /// 批量删除素材信息
28         /// </summary>
29         /// <param name="input"></param>
30         /// <returns></returns>
31         Task BatchDeleteArticle(IEnumerable<Guid> idList);
32 
33         /// <summary>
34         /// 获取指定的素材信息
35         /// </summary>
36         /// <param name="id"></param>
37         /// <returns></returns>
38         Task<ArticleDto> GetArticle(Guid id);
39 
40         /// <summary>
41         /// 查询素材列表信息(Table)
42         /// </summary>
43         /// <param name="input"></param>
44         /// <returns></returns>
45         Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input);
46 
47         #endregion
48     }
49 }

 

Application层的ArticleAppService实现类:

 1 namespace Fami.WechatMp
 2 {
 3     public class ArticleAppService : FamiAppServiceBase, IArticleAppService
 4     {
 5         private readonly IWechatMpRepository<ArticleCategory> _articleCategoryRepository;
 6         private readonly IWechatMpRepository<Article> _articleRepository;
 7         private readonly IArticlePolicy _articlePolicy;
 8 
 9         public ArticleAppService(
10             IWechatMpRepository<ArticleCategory> articleCategoryRepository,
11             IWechatMpRepository<Article> articleRepository,
12             IArticlePolicy articlePolicy
13             )
14         {
15             _articleCategoryRepository = articleCategoryRepository;
16             _articleRepository = articleRepository;
17             _articlePolicy = articlePolicy;
18         }
19 
20         public async Task<IEnumerable<ArticleCategoryDto>> GetArticleCategories()
21         {
22             var query = _articleCategoryRepository.GetAll().OrderBy(item => item.DisplayOrder);
23             return await query.Query().To<ArticleCategoryDto>().Take(100).ToListAsync();
24         }
25 
26         public async Task<ArticleDto> CreateArticle(ArticleDto model)
27         {
28             if (await _articlePolicy.IsExistsArticleByName(model.Title))
29             {
30                 throw new UserFriendlyException(L("NameIsExists"));
31             }
32             var entity = await _articleRepository.InsertAsync(model.MapTo<Article>());
33             return entity.MapTo<ArticleDto>();
34         }
35 
36         public async Task UpdateArticle(ArticleDto model)
37         {
38             if (await _articlePolicy.IsExistsArticleByName(model.Title, model.Id))
39             {
40                 throw new UserFriendlyException(L("NameIsExists"));
41             }
42             var entity = await _articleRepository.GetAsync(model.Id);
43             await _articleRepository.UpdateAsync(model.MapTo(entity));
44         }
45 
46         public async Task BatchDeleteArticle(IEnumerable<Guid> idList)
47         {
48             if (await _articlePolicy.IsExistsByArticleAutoreplySetting(idList.ToList()))
49             {
50                 throw new UserFriendlyException(L("AutoreplyArticleIsExists"));
51             }
52             await _articleRepository.BatchDeleteAsync(idList);
53         }
54 
55         public async Task<ArticleDto> GetArticle(Guid id)
56         {
57             var entity = await _articleRepository.GetAsync(id);
58             return entity.MapTo<ArticleDto>();
59         }
60 
61         /// <summary>
62         /// 根据查询条件,返回文章列表数据
63         /// </summary>
64         /// <param name="input">查询条件</param>
65         /// <returns></returns>
66         public async Task<QueryResultOutput<ArticleItem>> GetArticleList(GetArticleListInput input)
67         {
68             var query = _articleRepository.GetAll()
69                 .WhereIf(input.ArticleCategoryId.HasValue, m => m.ArticleCategoryId == input.ArticleCategoryId.Value)
70                 .WhereIf(!input.Keywords.IsNullOrWhiteSpace(), m => m.Title.Contains(input.Keywords));
71 
72             var result = await query.Query(input).ToAsync<ArticleItem>();
73             return result;
74         }
75     }
76 }

 

ArticleController.cs代码如下:

 1 namespace Fami.Mc.Web.Controllers
 2 {
 3     public class ArticleController : FamiControllerBase
 4     {
 5         private readonly IArticleAppService _articleAppService;
 6 
 7         public ArticleController(IArticleAppService articleAppService)
 8         {
 9             _articleAppService = articleAppService;
10         }
11 
12         public async Task<ActionResult> Index()
13         {
14             ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories();
15             return View();
16         }
17 
18         public async Task<ActionResult> Edit(Guid? id)
19         {
20             ArticleDto model;
21             if (!id.HasValue)  //新建
22             {
23                 model = new ArticleDto();
24                 ViewBag.ActionName = "createArticle";
25             }
26             else  //编辑
27             {
28                 model = await _articleAppService.GetArticle(id.Value);
29                 ViewBag.ActionName = "updateArticle";
30             }
31             ViewBag.ArticleCategoryDtos = await _articleAppService.GetArticleCategories();
32             return View(model);
33         }
34     }
35 }

 

Views/Article/Index.cshtml代码(列表页):

 1 <div class="page-content">
 2     <div class="page-header">
 3         <div class="page-title">文章管理</div>
 4         <!-- 过滤条件start -->
 5         <div >
 6             <div class="clearfix" style="margin-right:30px;">
 7                 <div class="clearfix pull-left" style="line-height: 30px; margin: 3px 5px; ">
 8                     <div class="pull-left">分类:</div>
 9                     <div  

鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
ASP.NET中数据库操作初步发布时间:2022-07-10
下一篇:
asp.netjQueryAjax用户登录功能的实现发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap