手记

EF Core 中实现 动态数据过滤器

前言

  在项目开发中,我们很多时候都会设计  软删除、所属用户 等等一系列字段 来方便我们在业务查询的时候进行各种过滤

  然后引申的问题就是:

    在业务查询的时候,我们要如何加上这些条件?或者动态禁用某些查询条件呢?

EF Core自带的全局过滤查询功能

  EF Core提供了一个HasQueryFilter 供我们在查询的时候进行预置部分筛选条件 

  例如:

      builder.HasQueryFilter(x => !x.IsDelete);

  这样查询的时候  EF Core 会自动帮我们实现过滤

  然后如果不想使用的时候可以全部忽略      

    DbSet.IgnoreQueryFilters();

  咋一看 很完美  

  然后我们实际操作的时候

  1.我是不是每个Entity里面是不是都要配置一次呢?

  2.我只想禁用部分筛选条件呢?

  3.我的查询条件的某些参数要动态呢?

    例如和用户相关的数据等等

    (有些人可能会说 我想办法把User的信息注入到DbContext里面不就可以了  假如我还要别的信息呢  还是接着注入?)

  这就是理论和实践之间的差距

  然后再网上找好久,找到了 EntityFramework-Plus  (开源免费)

  https://github.com/zzzprojects/EntityFramework-Plus

     官网地址: http://entityframework-plus.net/  

  内置了很多功能  本篇只针对查询过滤做说嘛

 

 EntityFramework-Plus 查询过滤功能

  1.QueryFilterManager

  QueryFilterManager 主要用来预设全局过滤

  例如:

  QueryFilterManager.Filter<Customer>(q => q.Where(x => x.IsActive));

  var ctx = new EntitiesContext();

  QueryFilterManager.InitilizeGlobalFilter(ctx);

  这样即可。。。

  但是需要提前注意的是 QueryFilterManager 预设后是无法更改的

  无法更改这是在  谷歌的时候  作者正好回复的别人的时候看到的

  就和我们之前第三点  动态 冲突了

  然后只能再看别的方式了

  2.Filter

  Z.EntityFramework.Plus 提供了 通过DbContext 的扩展方式来进行注入筛选条件的方式

  例如:

  var ctx = new EntitiesContext();

  ctx.Filter<IAnimal>(MyEnum.EnumValue, q => q.Where(x => x.IsDomestic))

  //禁用指定键值查询条件

  ctx.Filter(MyEnum.EnumValue).Disable();

  var dogs = ctx.Dogs.ToList();

  //启用指定键值查询条件

  ctx.Filter(MyEnum.EnumValue).Enable();

  // SELECT * FROM Dog WHERE IsDomestic = true
  var dogs = ctx.Dogs.ToList();

  这样好像符合我们的需求  

  3.AsNoFilter

  禁用条件

  例如: 

  var ctx = new EntitiesContext();

  this.Filter<Customer>(q => q.Where(x => x.IsActive));

  // SELECT * FROM Customer WHERE IsActive = true
  var list = ctx.Customers.ToList();

  // SELECT * FROM Customer
  var list = ctx.Customers.AsNoFilter().ToList();

 

  AsNoFilter()后如何启用  指定查询条件  作者好像没有做相应扩展 ,后面会给出对应扩展方法

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

说了这么多 理论补完了   实际操作的时候呢?

  1.这些条件如何注入进来呢?

  2.如何可以让我任意扩展呢?

     3.假如我们操作时通过仓储 ,而不是  直接通过DbContext 呢?

 

 

如何封装

 这边演示通过我自己的开源项目做为事例:

  github  : https://github.com/wulaiwei/WorkData.Core

  主要依赖的框架

  1.AutoFac

  2.EF Core

  3.Z.EntityFramework.Plus

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

  对于我们来说 我们无论使用多少个数据筛选器  返回的都应该是同一个返回值  ,我们去看 DbContext.Filter(....)  会发现他的返回值都是 BaseQueryFilter

  针对这个  我们可以得到两条信息  我们需要 传入 DbContext  和 一个返回值为  BaseQueryFilter  的方法

  所以 我们定义如下接口 IDynamicFilter

1     public interface IDynamicFilter2     {3         BaseQueryFilter InitFilter(DbContext dbContext);4     }

  这样我们这边就得到了一个标准

  例如 我们我们需要一个 所属用户和  软删除 的数据筛选器   我们只需要继承他即可

  我们如何区分他们呢?

  我们在之前使用 Z.EntityFramework.Plus  是看到了  可以设置筛选器的Key

  所以 我们也同样扩展个属性 DynamicFilterAttribute  来作为他们的名字

1     public class DynamicFilterAttribute: Attribute2     {3         /// <summary>4         /// Name5         /// </summary>6         public string Name { get; set; }7 8     }

  然后我们定义我们的  所属用户和  软删除 的数据筛选器  并为他们设置名称

  CreateDynamicFilter

 1  /// <summary> 2     /// CreateDynamicFilter 3     /// </summary> 4     [DynamicFilter(Name = "CreateUserId")] 5     public class CreateDynamicFilter : IDynamicFilter 6     { 7         /// <summary> 8         /// InitFilter 9         /// </summary>10         /// <param name="dbContext"></param>11         /// <returns></returns>12         public BaseQueryFilter InitFilter(DbContext dbContext)13         {14             var workdataSession = IocManager.Instance.Resolve<IWorkDataSession>();15             if (workdataSession == null)16                 return dbContext17                     .Filter<ICreate>("CreateUserId", x => x.Where(w => w.CreateUserId == string.Empty ));18 19             return dbContext20                 .Filter<ICreate>("CreateUserId", x => x.Where(w => w.CreateUserId == workdataSession.UserId || w.CreateUserId == ""));21         }22     }

  说明:

  var workdataSession = IocManager.Instance.Resolve<IWorkDataSession>();

  用来获取你所需要的 传参 

     IocManager.Instance.Resolve  是WorkData  关于Ioc的封装  源码可以参见git  或者上一篇博客

  SoftDeleteDynamicFilter

 1 /// <summary> 2     /// SoftDeleteDynamicFilter 3     /// </summary> 4     [DynamicFilter(Name = "SoftDelete")] 5     public class SoftDeleteDynamicFilter: IDynamicFilter 6     { 7         public BaseQueryFilter InitFilter(DbContext dbContext) 8         { 9             return dbContext10                 .Filter<IsSoftDelete>("SoftDelete", x => x.Where(w => !w.IsDelete));11         }12     }

   这样 我们所有接口 和实现定义好了 如何管理呢?

  1.将继承 IDynamicFilter 的注入到Ioc里面

 1             #region 动态审计注入 2             var filterTypes = _typeFinder.FindClassesOfType<IDynamicFilter>(); 3  4             foreach (var filterType in filterTypes) 5             { 6                 var dynamicFilterAttribute = filterType.GetCustomAttribute(typeof(DynamicFilterAttribute)) as DynamicFilterAttribute; 7                 if (dynamicFilterAttribute == null) 8                     continue; 9 10                 builder.RegisterType(filterType).Named<IDynamicFilter>(dynamicFilterAttribute.Name);11             }12             #endregion

  说明:

  1.ITypeFinder 是从 nopcommerce 抽离出来的反射方法   已集成到WorkData  百度即可查询到相应说明文档

  2.通过 GetCustomAttribute  获取 DynamicFilterAttribute 的属性名称  作为注册到Ioc名称

  2.如何设置一个启用数据筛选器呢?我们这边定义个配置文件  通过 .net core 提供的程序进行配置文件注入

1     /// <summary>2     /// 动态拦截器配置3     /// </summary>4     public class DynamicFilterConfig5     {6         public List<string> DynamicFilterList{ get; set; }7     }

"DynamicFilterConfig": {

"DynamicFilterList": [ "CreateUserId", "SoftDelete" ]
}

 

如何注入配置文件 可以通过百度或者查看workdata源码 即可 这不做说明

  3.如何管理呢?什么时候统一添加到 DbContext呢?

  我们这边定义一个DynamicFilterManager 提供一个 字典集合 来暂存所以的 IDynamicFilter,同时提供一个方法来进行初始化值

 1     public static class DynamicFilterManager 2     { 3         static DynamicFilterManager() 4         { 5             CacheGenericDynamicFilter = new Dictionary<string, IDynamicFilter>(); 6         } 7  8         /// <summary> 9         ///     CacheGenericDynamicFilter10         /// </summary>11         public static Dictionary<string, IDynamicFilter> CacheGenericDynamicFilter { get; set; }12 13         /// <summary>14         ///     AddDynamicFilter15         /// </summary>16         /// <param name="dbContext"></param>17         /// <returns></returns>18         public static void AddDynamicFilter(this DbContext dbContext)19         {20             if (dbContext == null) return;21             foreach (var dynamicFilter in CacheGenericDynamicFilter) dynamicFilter.Value.InitFilter(dbContext);22         }23 24         /// <summary>25         ///     AsWorkDataNoFilter26         /// </summary>27         /// <typeparam name="T"></typeparam>28         /// <param name="query"></param>29         /// <param name="context"></param>30         /// <param name="filterStrings"></param>31         /// <returns></returns>32         public static IQueryable<T> AsWorkDataNoFilter<T>(this DbSet<T> query, DbContext context,33             params object[] filterStrings) where T : class34         {35             var asNoFilterQueryable = query.AsNoFilter();36 37             object query1 = asNoFilterQueryable;38             var items = CacheGenericDynamicFilter.Where(x => filterStrings.Contains(x.Key));39 40             query1 = items.Select(key => context.Filter(key.Key)).Where(item => item != null)41                 .Aggregate(query1, (current, item) => (IQueryable) item.ApplyFilter<T>(current));42             return (IQueryable<T>) query1;43         }44 45         /// <summary>46         ///     SetCacheGenericDynamicFilter47         /// </summary>48         public static void SetCacheGenericDynamicFilter()49         {50             var dynamicFilterConfig = IocManager.Instance.ResolveServiceValue<DynamicFilterConfig>();51 52             foreach (var item in dynamicFilterConfig.DynamicFilterList)53             {54                 var dynamicFilter = IocManager.Instance.ResolveName<IDynamicFilter>(item);55                 CacheGenericDynamicFilter.Add(item, dynamicFilter);56             }57         }58     }

  然后我们在DbContext里面的 OnModelCreating 进行初始化

 1  /// <summary> 2         ///     重写模型创建函数 3         /// </summary> 4         /// <param name="modelBuilder"></param> 5         protected override void OnModelCreating(ModelBuilder modelBuilder) 6         { 7             base.OnModelCreating(modelBuilder); 8  9             //初始化对象10             DynamicFilterManager.SetCacheGenericDynamicFilter();11         }

  初始化完成后如何将条件付给  DbContext 呢?

  在DynamicFilterManager 中我们提供了一个扩展方法 AddDynamicFilter 你可以在你创建 DbContext 的时候调用

 1      /// <summary> 2         ///     AddDynamicFilter 3         /// </summary> 4         /// <param name="dbContext"></param> 5         /// <returns></returns> 6         public static void AddDynamicFilter(this DbContext dbContext) 7         { 8             if (dbContext == null) return; 9             foreach (var dynamicFilter in CacheGenericDynamicFilter) dynamicFilter.Value.InitFilter(dbContext);10         }

  在WorkData中  我们则需要在EfContextFactory 进行调用

  dbContext = _resolver.Resolve<TDbContext>();

  //初始化拦截器
  dbContext.AddDynamicFilter();

 1   /// <summary> 2     ///     EfContextFactory 3     /// </summary> 4     public class EfContextFactory : IEfContextFactory 5     { 6         private readonly IResolver _resolver; 7  8         public EfContextFactory(IResolver resolver) 9         {10             _resolver = resolver;11         }12 13         /// <summary>14         ///     default current context15         /// </summary>16         /// <param name="dic"></param>17         /// <param name="tranDic"></param>18         /// <returns></returns>19         public TDbContext GetCurrentDbContext<TDbContext>(Dictionary<string, DbContext> dic, Dictionary<DbContext, IDbContextTransaction> tranDic)20             where TDbContext : DbContext21         {22             return GetCurrentDbContext<TDbContext>(dic, tranDic, string.Empty);23         }24 25         /// <summary>26         ///GetCurrentDbContext27         /// </summary>28         /// <typeparam name="TDbContext"></typeparam>29         /// <param name="dic"></param>30         /// <param name="tranDic"></param>31         /// <param name="conString"></param>32         /// <returns></returns>33         public TDbContext GetCurrentDbContext<TDbContext>(Dictionary<string, DbContext> dic, Dictionary<DbContext, IDbContextTransaction> tranDic, string conString)34             where TDbContext : DbContext35         {36             conString = typeof(TDbContext).ToString();37             var dbContext = dic.ContainsKey(conString + "DbContext") ? dic[conString + "DbContext"] : null;38             try39             {40                 if (dbContext != null)41                 {42                     return (TDbContext)dbContext;43                 }44             }45             catch (Exception)46             {47                 dic.Remove(conString + "DbContext");48             }49             dbContext = _resolver.Resolve<TDbContext>();50 51             //初始化拦截器52             dbContext.AddDynamicFilter();53 54             //我们在创建一个,放到数据槽中去55             dic.Add(conString + "DbContext", dbContext);56 57             //开始事务58             var tran = dbContext.Database.BeginTransaction();59             tranDic.Add(dbContext, tran);60 61             return (TDbContext)dbContext;62         }63     }

  这样我们的筛选器已经全部注入完成了

  还剩下一个我们之前说的  

  AsNoFilter()后如何启用  指定查询条件  作者好像没有做相应扩展 ,后面会给出对应扩展方法

  通过查看源码后 

 1     /// <summary> 2     ///     Filter the query using context filters associated with specified keys. 3     /// </summary> 4     /// <typeparam name="T">The type of elements of the query.</typeparam> 5     /// <param name="query">The query to filter using context filters associated with specified keys.</param> 6     /// <param name="keys"> 7     ///     A variable-length parameters list containing keys associated to context filters to use to filter the 8     ///     query. 9     /// </param>10     /// <returns>The query filtered using context filters associated with specified keys.</returns>11     public static IQueryable<T> Filter<T>(this DbSet<T> query, params object[] keys) where T : class12     {13       BaseQueryFilterQueryable filterQueryable = QueryFilterManager.GetFilterQueryable((IQueryable) query);14       IQueryable<T> query1 = filterQueryable != null ? (IQueryable<T>) filterQueryable.OriginalQuery : (IQueryable<T>) query;15       return QueryFilterManager.AddOrGetFilterContext(filterQueryable != null ? filterQueryable.Context : InternalExtensions.GetDbContext<T>(query)).ApplyFilter<T>(query1, keys);16     }

  Z.EntityFramework.Plus  提供了一个 ApplyFilter  所以 我们基于这个 做个扩展

 1   /// <summary> 2         ///     AsWorkDataNoFilter 3         /// </summary> 4         /// <typeparam name="T"></typeparam> 5         /// <param name="query"></param> 6         /// <param name="context"></param> 7         /// <param name="filterStrings"></param> 8         /// <returns></returns> 9         public static IQueryable<T> AsWorkDataNoFilter<T>(this DbSet<T> query, DbContext context,10             params object[] filterStrings) where T : class11         {12             var asNoFilterQueryable = query.AsNoFilter();13 14             object query1 = asNoFilterQueryable;15             var items = CacheGenericDynamicFilter.Where(x => filterStrings.Contains(x.Key));16 17             query1 = items.Select(key => context.Filter(key.Key)).Where(item => item != null)18                 .Aggregate(query1, (current, item) => (IQueryable) item.ApplyFilter<T>(current));19             return (IQueryable<T>) query1;20         }

这样 我们可以传入指定的筛选器名称  启用自己想要的

最终我们的仓储就变成了这样:

  1  /// <summary>  2     ///     EfBaseRepository  3     /// </summary>  4     /// <typeparam name="TEntity"></typeparam>  5     /// <typeparam name="TPrimaryKey"></typeparam>  6     /// <typeparam name="TDbContext"></typeparam>  7     public class EfBaseRepository<TDbContext, TEntity, TPrimaryKey> :  8         BaseRepository<TEntity, TPrimaryKey>,  9         IRepositoryDbConntext where TEntity : class, IAggregateRoot, IEntity<TPrimaryKey> 10         where TDbContext : DbContext 11     { 12         //public IQueryable<EntityType> EntityTypes => Context.Model.EntityTypes.Where(t => t.Something == true); 13  14         private readonly IDbContextProvider<TDbContext> _dbContextProvider; 15         private readonly IPredicateGroup<TEntity> _predicateGroup; 16  17         public EfBaseRepository( 18             IDbContextProvider<TDbContext> dbContextProvider, 19             IPredicateGroup<TEntity> predicateGroup) 20         { 21             _dbContextProvider = dbContextProvider; 22             _predicateGroup = predicateGroup; 23         } 24  25         /// <summary> 26         ///     Gets EF DbContext object. 27         /// </summary> 28         public TDbContext Context => _dbContextProvider.GetContent(); 29  30         /// <summary> 31         ///     Gets DbSet for given entity. 32         /// </summary> 33         public virtual DbSet<TEntity> DbSet => Context.Set<TEntity>(); 34  35         #region DbContext 36  37         /// <summary> 38         ///     GetDbContext 39         /// </summary> 40         /// <returns></returns> 41         public DbContext GetDbContext() 42         { 43             return Context; 44         } 45  46         #endregion 47  48         #region Query 49  50  51  52         /// <summary> 53         ///     FindBy 54         /// </summary> 55         /// <param name="primaryKey"></param> 56         /// <returns></returns> 57         public override TEntity FindBy(TPrimaryKey primaryKey) 58         { 59             var entity = DbSet.Find(primaryKey); 60             return entity; 61         } 62  63         /// <summary> 64         /// FindBy 65         /// </summary> 66         /// <param name="primaryKey"></param> 67         /// <param name="includeNames"></param> 68         /// <returns></returns> 69         public override TEntity FindBy(TPrimaryKey primaryKey, string[] includeNames) 70         { 71             var query = DbSet; 72             foreach (var includeName in includeNames) 73             { 74                 query.Include(includeName); 75             } 76             var entity = query.Find(primaryKey); 77             return entity; 78         } 79  80         /// <summary> 81         ///     AsNoFilterFindBy 82         /// </summary> 83         /// <param name="primaryKey"></param> 84         /// <returns></returns> 85         public override TEntity AsNoFilterFindBy(TPrimaryKey primaryKey) 86         { 87             var entity = DbSet.AsNoFilter() 88                 .SingleOrDefault(x => x.Id.Equals(primaryKey)); 89             return entity; 90         } 91  92         /// <summary> 93         /// AsNoFilterFindBy 94         /// </summary> 95         /// <param name="primaryKey"></param> 96         /// <param name="includeNames"></param> 97         /// <returns></returns> 98         public override TEntity AsNoFilterFindBy(TPrimaryKey primaryKey, string[] includeNames) 99         {100 101             var query = DbSet.AsNoFilter();102             foreach (var includeName in includeNames)103             {104                 query.Include(includeName);105             }106             var entity = query.SingleOrDefault(x => x.Id.Equals(primaryKey));107 108             return entity;109         }110 111 112         /// <summary>113         ///     FindBy114         /// </summary>115         /// <param name="primaryKey"></param>116         /// <param name="filterStrings"></param>117         /// <returns></returns>118         public override TEntity FindBy(TPrimaryKey primaryKey, params object[] filterStrings)119         {120             var entity = DbSet.AsWorkDataNoFilter(Context, filterStrings)121                 .SingleOrDefault(x => x.Id.Equals(primaryKey));122             return entity;123         }124 125         /// <summary>126         /// FindBy127         /// </summary>128         /// <param name="primaryKey"></param>129         /// <param name="includeNames"></param>130         /// <param name="filterStrings"></param>131         /// <returns></returns>132         public override TEntity FindBy(TPrimaryKey primaryKey, string[] includeNames, params object[] filterStrings)133         {134             var query = DbSet.AsWorkDataNoFilter(Context, filterStrings);135             foreach (var includeName in includeNames)136             {137                 query.Include(includeName);138             }139             var entity = query.SingleOrDefault(x => x.Id.Equals(primaryKey));140 141             return entity;142         }143 144 145         /// <summary>146         ///     GetAll147         /// </summary>148         /// <returns></returns>149         public override IQueryable<TEntity> GetAll()150         {151             return DbSet;152         }153 154 155         /// <summary>156         /// GetAll157         /// </summary>158         /// <param name="includeNames"></param>159         /// <returns></returns>160         public override IQueryable<TEntity> GetAll(string[] includeNames)161         {162             var query = DbSet;163             foreach (var includeName in includeNames)164             {165                 query.Include(includeName);166             }167             return query;168         }169 170         /// <summary>171         /// GetAll172         /// </summary>173         /// <param name="filterStrings"></param>174         /// <returns></returns>175         public override IQueryable<TEntity> GetAll(params object[] filterStrings)176         {177             return DbSet.AsWorkDataNoFilter(Context, filterStrings);178         }179 180         /// <summary>181         /// GetAll182         /// </summary>183         /// <param name="includeNames"></param>184         /// <param name="filterStrings"></param>185         /// <returns></returns>186         public override IQueryable<TEntity> GetAll(string[] includeNames, params object[] filterStrings)187         {188             var query = DbSet.AsWorkDataNoFilter(Context, filterStrings);189 190             foreach (var includeName in includeNames)191             {192                 query.Include(includeName);193             }194             return query;195         }196 197         /// <summary>198         /// AsNoFilterGetAll199         /// </summary>200         /// <returns></returns>201         public override IQueryable<TEntity> AsNoFilterGetAll()202         {203             return DbSet.AsNoFilter();204         }205 206         /// <summary>207         /// AsNoFilterGetAll208         /// </summary>209         /// <param name="includeNames"></param>210         /// <returns></returns>211         public override IQueryable<TEntity> AsNoFilterGetAll(string[] includeNames)212         {213             var query = DbSet.AsNoFilter();214 215             foreach (var includeName in includeNames)216             {217                 query.Include(includeName);218             }219             return query;220         }221         #endregion222 223         #region Insert224 225         /// <summary>226         ///     Insert227         /// </summary>228         /// <typeparam name="TEntity"></typeparam>229         /// <param name="model"></param>230         public override TEntity Insert(TEntity model)231         {232             return DbSet.Add(model).Entity;233         }234 235         /// <summary>236         ///     InsertGetId237         /// </summary>238         /// <param name="model"></param>239         /// <returns></returns>240         public override TPrimaryKey InsertGetId(TEntity model)241         {242             model = Insert(model);243 244             Context.SaveChanges();245 246             return model.Id;247         }248 249         /// <summary>250         ///     Insert251         /// </summary>252         /// <param name="entities"></param>253         public override void Insert(IEnumerable<TEntity> entities)254         {255             if (entities == null)256                 throw new ArgumentNullException(nameof(entities));257 258             DbSet.AddRange(entities);259 260             Context.SaveChanges();261         }262 263         #endregion264 265         #region Delete266 267         /// <summary>268         ///     Delete269         /// </summary>270         /// <param name="entity"></param>271         public override void Delete(TEntity entity)272         {273             DbSet.Remove(entity);274             Context.SaveChanges();275         }276 277         /// <summary>278         ///     Delete279         /// </summary>280         /// <param name="entities"></param>281         public override void Delete(IEnumerable<TEntity> entities)282         {283             if (entities == null)284                 throw new ArgumentNullException(nameof(entities));285 286             DbSet.RemoveRange(entities);287 288             Context.SaveChanges();289         }290 291         #endregion292 293         #region Update294 295         /// <summary>296         ///     Update297         /// </summary>298         /// <param name="entity"></param>299         public override void Update(TEntity entity)300         {301             DbSet.Update(entity);302             Context.SaveChanges();303         }304 305         /// <summary>306         ///     Update307         /// </summary>308         /// <param name="entities"></param>309         public override void Update(IEnumerable<TEntity> entities)310         {311             if (entities == null)312                 throw new ArgumentNullException(nameof(entities));313 314             DbSet.UpdateRange(entities);315 316             Context.SaveChanges();317         }318 319         #endregion320     }

  说明:仓储的设计理念是从  ABP中抽离出来的 

  最后附测试  

启用的筛选器为 "CreateUserId", "SoftDelete"

 1  /// <summary> 2         ///     Index 3         /// </summary> 4         /// <returns></returns> 5         public IActionResult Index() 6         { 7             _baseRepository.GetAll().ToList(); 8             _baseRepository.GetAll("CreateUserId","xxx假定不存在的筛选器").ToList(); 9             _baseRepository.AsNoFilterGetAll().ToList();10 11             _baseRepository.FindBy("1");12             _baseRepository.FindBy("1", "CreateUserId", "xxx假定不存在的筛选器");13             _baseRepository.AsNoFilterFindBy("1");14             return View();15         }

原文出处:https://www.cnblogs.com/wulaiwei/p/9561830.html

0人推荐
随时随地看视频
慕课网APP