• Entity Framework 实体框架的形成之旅--为基础类库接口增加单元测试,对基类接口进行正确性校验(10)


    本篇介绍Entity Framework 实体框架的文章已经到了第十篇了,对实体框架的各个分层以及基类的封装管理,已经臻于完善,为了方便对基类接口的正确性校验,以及方便对以后完善或扩展接口进行回归测试,那么建立单元测试就有很大的必要,本篇主要介绍如何利用VS创建内置的单元测试项目进行实体框架的基类接口测试。

    在采用单元测试这个事情上,很多人可能想到了NUnit单元测试工具和NMock工具进行处理,其实微软VS里面也已经为我们提供了类似的单元测试工具了,可以不需要使用这个第三方的单元测试工具,经试用VS的单元测试工具还是整合性很好,使用非常方便的。

    1、实体框架架构及基础类库接口

     在上次的随笔《Entity Framework 实体框架的形成之旅--数据传输模型DTO和实体模型Entity的分离与联合》里面,我根据实体框架中混合模式的框架结构,所涉及的架构图形如下所示。

    我们从上图可以看到,整个框架从下往上分为了几个明显的层次,一个数据访问层DAL层,一个是业务逻辑层BLL层,一个是Facade门面层,各个层的功能不同,这几个层中以DAL层最为复杂一些,涉及到底层多种数据库的抽象实现,由于Entity Framework 实体框架本身就是对多种数据库的实现抽象,因此本文重点针对这个DAL层进行单元测试。

    其中的实体框架的公用类库(WHC.Framework.EF),里面涉及到的IBaseDAL就是数据访问层的基类接口,具体数据访问的抽象实现就在BaseDAL的基类上。

    在IBaseDAL接口里面,定义了很多我们数据访问类需要使用的增删改查、分页、统计、辅助方法等接口,以及各个方法的异步方法接口,如下所示。

    namespace WHC.Framework.EF
    {
        /// <summary>
        /// 数据访问层基类接口
        /// </summary>
        /// <typeparam name="T">实体对象类型</typeparam>
        public interface IBaseDAL<T> where T : class
        {
            #region 对象添加、修改、删除
    
            /// <summary>
            /// 插入指定对象到数据库中
            /// </summary>
            /// <param name="t">指定的对象</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool Insert(T t);
    
            /// <summary>
            /// 插入指定对象到数据库中(异步)
            /// </summary>
            /// <param name="t">指定的对象</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> InsertAsync(T t);
    
            /// <summary>
            /// 插入指定对象集合到数据库中
            /// </summary>
            /// <param name="list">指定的对象集合</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool InsertRange(IEnumerable<T> list);
    
            /// <summary>
            /// 插入指定对象集合到数据库中(异步)
            /// </summary>
            /// <param name="list">指定的对象集合</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> InsertRangeAsync(IEnumerable<T> list);
    
            /// <summary>
            /// 更新对象属性到数据库中
            /// </summary>
            /// <param name="t">指定的对象</param>
            /// <param name="key">主键的值</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool Update(T t, object key);
    
            /// <summary>
            /// 更新对象属性到数据库中(异步)
            /// </summary>
            /// <param name="t">指定的对象</param>
            /// <param name="key">主键的值</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> UpdateAsync(T t, object key);
    
            /// <summary>
            /// 根据指定对象的ID,从数据库中删除指定对象
            /// </summary>
            /// <param name="id">对象的ID</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool Delete(object id);
    
            /// <summary>
            /// 根据指定对象的ID,从数据库中删除指定对象(异步)
            /// </summary>
            /// <param name="id">对象的ID</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> DeleteAsync(object id);
    
            /// <summary>
            /// 从数据库中删除指定对象
            /// </summary>
            /// <param name="t">指定对象</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool Delete(T t);
    
            /// <summary>
            /// 从数据库中删除指定对象(异步)
            /// </summary>
            /// <param name="t">指定对象</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> DeleteAsync(T t);
    
            /// <summary>
            /// 根据指定条件,从数据库中删除指定对象
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool DeleteByExpression(Expression<Func<T, bool>> match);
    
            /// <summary>
            /// 根据指定条件,从数据库中删除指定对象(异步)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> DeleteByExpressionAsync(Expression<Func<T, bool>> match);
    
            /// <summary>
            /// 根据指定条件,从数据库中删除指定对象
            /// </summary>
            /// <param name="condition">删除记录的条件语句</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            bool DeleteByCondition(string condition);
    
            /// <summary>
            /// 根据指定条件,从数据库中删除指定对象(异步)
            /// </summary>
            /// <param name="condition">删除记录的条件语句</param>
            /// <returns>执行成功返回<c>true</c>,否则为<c>false</c></returns>
            Task<bool> DeleteByConditionAsync(string condition);
    
            #endregion

    或者一些其他的分页等复杂的实现接口。

            #region 返回集合的接口
    
            /// <summary>
            /// 返回可查询的记录源
            /// </summary>
            /// <returns></returns>
            IQueryable<T> GetQueryable();
    
            /// <summary>
            /// 根据条件表达式返回可查询的记录源
            /// </summary>
            /// <param name="match">查询条件</param>
            /// <param name="sortPropertyName">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            IQueryable<T> GetQueryable(Expression<Func<T, bool>> match, string sortPropertyName, bool isDescending = true);
    
            /// <summary>
            /// 根据条件表达式返回可查询的记录源
            /// </summary>
            /// <param name="match">查询条件</param>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            IQueryable<T> GetQueryable<TKey>(Expression<Func<T, bool>> match, Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            /// <summary>
            /// 返回数据库所有的对象集合
            /// </summary>
            /// <returns></returns>
            IList<T> GetAll();
    
            /// <summary>
            /// 返回数据库所有的对象集合(异步)
            /// </summary>
            /// <returns></returns>
            Task<IList<T>> GetAllAsync();
    
            /// <summary>
            /// 返回数据库所有的对象集合
            /// </summary>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            IList<T> GetAll<TKey>(Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            /// <summary>
            /// 返回数据库所有的对象集合(异步)
            /// </summary>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            Task<IList<T>> GetAllAsync<TKey>(Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            /// <summary>
            /// 返回数据库所有的对象集合(用于分页数据显示)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="info">分页实体</param>
            /// <returns>指定对象的集合</returns>
            IList<T> GetAllWithPager(PagerInfo info);
    
            /// <summary>
            /// 返回数据库所有的对象集合(用于分页数据显示,异步)
            /// </summary>
            /// <param name="info">分页实体</param>
            /// <returns>指定对象的集合</returns>
            Task<IList<T>> GetAllWithPagerAsync(PagerInfo info);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <returns></returns>
            IList<T> Find(Expression<Func<T, bool>> match);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合(异步)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <returns></returns>
            Task<IList<T>> FindAsync(Expression<Func<T, bool>> match);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            IList<T> Find<TKey>(Expression<Func<T, bool>> match, Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合(异步)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns></returns>
            Task<IList<T>> FindAsync<TKey>(Expression<Func<T, bool>> match, Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合(用于分页数据显示)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="info">分页实体</param>
            /// <returns>指定对象的集合</returns>
            IList<T> FindWithPager(Expression<Func<T, bool>> match, PagerInfo info);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合(用于分页数据显示,异步)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="info">分页实体</param>
            /// <returns>指定对象的集合</returns>
            Task<IList<T>> FindWithPagerAsync(Expression<Func<T, bool>> match, PagerInfo info);
    
            /// <summary>
            /// 根据条件查询数据库,并返回对象集合(用于分页数据显示)
            /// </summary>
            /// <param name="match">条件表达式</param>
            /// <param name="info">分页实体</param>
            /// <param name="orderByProperty">排序表达式</param>
            /// <param name="isDescending">如果为true则为降序,否则为升序</param>
            /// <returns>指定对象的集合</returns>
            IList<T> FindWithPager<TKey>(Expression<Func<T, bool>> match, PagerInfo info, Expression<Func<T, TKey>> orderByProperty, bool isDescending = true);
    
            #endregion

    以及更多的方法接口,我们为了校验没有接口都能够正常工作,就需要对它们进行单元测试。

    2、在VS里面创建单元测试项目及编写单元测试代码

    在VS里面创建内置的单元测试项目如下所示,在添加新项目里面选择测试->单元测试项目即可,如下图所示。

    为了方便对基类测试,我们还是需要创建一个简单的代表性数据库用来检查基础的接口操作。

    由于前面的系列,已经介绍过了,我们在构建数据访问层的时候,使用的是基于IOC的方式构建一个对象的接口对象,如这样代码IFactory.Instance<IUserDAL>()所示。

    而单元测试,基本原理就是我们调用接口,并获取对应的输出结果,和我们预期的值进行对比,如果吻合就是正常通过的测试用例。

    为了进行基础类库的单元测试,我们需要根据实体框架的结构搭建一个具体表的对象项目工程,这个采用代码生成工具Database2Sharp进行生成就可以了,生成的处理操作如下所示。

    这样根据表快速生成的整个实体框架,就是我们所需要的实体框架项目,具体效果如下所示。

    例如我们创建一个查找记录的单元测试方法代码如下所示。

    namespace TestFrameworkEF
    {
        [TestClass]
        public class TestBaseDAL 
        {
            private string userId = Guid.NewGuid().ToString();
    
            [TestInitialize]
            public void Init()
            {
                User user = new User() { ID = userId, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
            }
    
            [TestCleanup]
            public void Cleanup()
            {
                bool result = IFactory.Instance<IUserDAL>().Delete(userId);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void FindByID()
            {
                User user = IFactory.Instance<IUserDAL>().FindByID(userId);
    
                Assert.IsNotNull(user);
                Assert.AreEqual(user.ID, userId);
            }

    其中上面红色代码部分就是单元测试的各种标识,包括单元测试类标识,以及初始化、退出清除、测试用例的标识。

    上面案例,我们是在单元测试前,在数据库写入一条记录,然后在进行各种单元测试用例的运行及校验,最后退出的时候,清除我们写入的记录。

    而记录的更新和删除接口,我们具体的单元测试代码如下所示。

            [TestMethod]
            public void Update()
            {
                string newAccount = "Test";
                User user = IFactory.Instance<IUserDAL>().FindByID(userId);
                user.Account = newAccount;
                bool result = IFactory.Instance<IUserDAL>().Update(user, user.ID);
                Assert.AreEqual(result, true);
    
                user = IFactory.Instance<IUserDAL>().FindByID(userId);
                Assert.IsNotNull(user);
                Assert.AreEqual(user.Account, newAccount);
            }
    
            [TestMethod]
            public void Delete()
            {
                var id = Guid.NewGuid().ToString();
                User user = new User() { ID = id, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
    
                result = IFactory.Instance<IUserDAL>().Delete(id);
                Assert.AreEqual(result, true);
            }

    最后我们整个单元测试的测试代码如下所示。

    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    using System.Threading.Tasks;
    using WHC.Framework.EF;
    using EFCore.IDAL;
    using EFCore.Entity;
    using Microsoft.VisualStudio.TestTools.UnitTesting;
    using System.Linq.Expressions;
    using System.Linq;
    using WHC.Pager.Entity;
    
    namespace TestFrameworkEF
    {
        [TestClass]
        public class TestBaseDAL 
        {
            private string userId = Guid.NewGuid().ToString();
    
            [TestInitialize]
            public void Init()
            {
                User user = new User() { ID = userId, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
            }
    
            [TestCleanup]
            public void Cleanup()
            {
                bool result = IFactory.Instance<IUserDAL>().Delete(userId);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void FindByID()
            {
                User user = IFactory.Instance<IUserDAL>().FindByID(userId);
    
                Assert.IsNotNull(user);
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void Insert()
            {
                var id = Guid.NewGuid().ToString();
                User user = new User() { ID = id , Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
    
                user = IFactory.Instance<IUserDAL>().FindByID(id);
                Assert.IsNotNull(user);
                Assert.AreEqual(user.ID, id);
    
                result = IFactory.Instance<IUserDAL>().Delete(id);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void InsertRang()
            {
                List<User> list = new List<User>();
                for(int i = 0; i<3; i++)
                {
                    var id = Guid.NewGuid().ToString();
                    User user = new User() { ID = id, Account = "Nunit" + i.ToString(), Password = "Nunit" };
                    list.Add(user);
                }
    
                bool result = IFactory.Instance<IUserDAL>().InsertRange(list);
                Assert.AreEqual(result, true);
    
                foreach(User user in list)
                {
                    result = IFactory.Instance<IUserDAL>().Delete(user.ID);
                    Assert.AreEqual(result, true);
                }
            }
    
            [TestMethod]
            public void Update()
            {
                string newAccount = "Test";
                User user = IFactory.Instance<IUserDAL>().FindByID(userId);
                user.Account = newAccount;
                bool result = IFactory.Instance<IUserDAL>().Update(user, user.ID);
                Assert.AreEqual(result, true);
    
                user = IFactory.Instance<IUserDAL>().FindByID(userId);
                Assert.IsNotNull(user);
                Assert.AreEqual(user.Account, newAccount);
            }
    
            [TestMethod]
            public void Delete()
            {
                var id = Guid.NewGuid().ToString();
                User user = new User() { ID = id, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
    
                result = IFactory.Instance<IUserDAL>().Delete(id);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void DeleteByExpression()
            {
                var id = Guid.NewGuid().ToString();
                User user = new User() { ID = id, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
    
                Expression<Func<User, bool>> expression = p => p.ID == user.ID && p.Account == user.Account;
                result = IFactory.Instance<IUserDAL>().DeleteByExpression(expression);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void DeleteByCondition()
            {
                var id = Guid.NewGuid().ToString();
                User user = new User() { ID = id, Account = "Nunit", Password = "Nunit" };
    
                bool result = IFactory.Instance<IUserDAL>().Insert(user);
                Assert.AreEqual(result, true);
    
                string condition = string.Format("ID ='{0}' ", id);
                result = IFactory.Instance<IUserDAL>().DeleteByCondition(condition);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void FindSingle()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                User dbUser = IFactory.Instance<IUserDAL>().FindSingle(expression);
                Assert.IsNotNull(dbUser);
                Assert.AreEqual(dbUser.ID, userId);
            }
    
            [TestMethod]
            public void GetQueryable()
            {
                User user = IFactory.Instance<IUserDAL>().GetQueryable().Take(1).ToList()[0];
                Assert.IsNotNull(user);
                //Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void GetQueryableExpression()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                User user = IFactory.Instance<IUserDAL>().GetQueryable(expression, "Account").Take(1).ToList()[0];
                Assert.IsNotNull(user);
                Assert.AreEqual(user.ID, userId);
            }
    
    
            [TestMethod]
            public void GetQueryableExpression2()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                User user = IFactory.Instance<IUserDAL>().GetQueryable(expression, s=>s.Account).Take(1).ToList()[0];
                Assert.IsNotNull(user);
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void GetAll()
            {
                User user =  IFactory.Instance<IUserDAL>().GetAll().Take(1).ToList()[0];
                Assert.IsNotNull(user);
            }
    
            [TestMethod]
            public void GetAllOrderBy()
            {
                User user = IFactory.Instance<IUserDAL>().GetAll(s=>s.Account).Take(1).ToList()[0];
                Assert.IsNotNull(user);
            }
    
            [TestMethod]
            public void GetAllWithPager()
            {
                PagerInfo pagerInfo = new PagerInfo();
                pagerInfo.PageSize = 30;
    
                User user = IFactory.Instance<IUserDAL>().GetAllWithPager(pagerInfo).Take(1).ToList()[0];
                Assert.IsNotNull(user);
            }
    
            [TestMethod]
            public void Find()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                User user = IFactory.Instance<IUserDAL>().Find(expression).Take(1).ToList()[0];
                Assert.IsNotNull(user);
    
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void Find2()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                User user = IFactory.Instance<IUserDAL>().Find(expression, s=>s.Account).Take(1).ToList()[0];
                Assert.IsNotNull(user);
    
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void FindWithPager()
            {
                PagerInfo pagerInfo = new PagerInfo();
                pagerInfo.PageSize = 30;
    
                Expression<Func<User, bool>> expression = p => p.ID == userId;
    
                User user = IFactory.Instance<IUserDAL>().FindWithPager(expression, pagerInfo).Take(1).ToList()[0];
                Assert.IsNotNull(user);
    
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void FindWithPager2()
            {
                PagerInfo pagerInfo = new PagerInfo();
                pagerInfo.PageSize = 30;
    
                Expression<Func<User, bool>> expression = p => p.ID == userId;
    
                User user = IFactory.Instance<IUserDAL>().FindWithPager(expression, pagerInfo, s=>s.Account).Take(1).ToList()[0];
                Assert.IsNotNull(user);
    
                Assert.AreEqual(user.ID, userId);
            }
    
            [TestMethod]
            public void GetRecordCount()
            {
                int count = IFactory.Instance<IUserDAL>().GetRecordCount();
                Assert.AreNotEqual(count, 0);
            }
    
            [TestMethod]
            public void GetRecordCount2()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                int count = IFactory.Instance<IUserDAL>().GetRecordCount(expression);
                Assert.AreNotEqual(count, 0);
            }
    
            [TestMethod]
            public void IsExistRecord()
            {
                bool result = IFactory.Instance<IUserDAL>().IsExistRecord(userId);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void IsExistRecord2()
            {
                Expression<Func<User, bool>> expression = p => p.ID == userId;
                bool result = IFactory.Instance<IUserDAL>().IsExistRecord(expression);
                Assert.AreEqual(result, true);
            }
    
            [TestMethod]
            public void SqlExecute()
            {
                string newAccount = "newAccount";
                string sql = string.Format("update [User] set Account='{0}' Where ID='{1}' ", newAccount, userId);
                int count = IFactory.Instance<IUserDAL>().SqlExecute(sql);
                Assert.AreEqual(count, 1);
            }
    
            [TestMethod]
            public void SqlValueList()
            {
                string sql = string.Format("Select ID From [User] ");
                ICollection<string> valueString = IFactory.Instance<IUserDAL>().SqlValueList(sql);
                Assert.IsNotNull(valueString);
                Assert.IsTrue(valueString.Count > 0);
            }
        }
    }

    3、运行单元测试

    代码编译没有问题后,我们需要检验我们的单元测试代码的正确性,那么只需要在VS的测试菜单里面,执行下面的操作即可。

    最后得到的运行结果如下所示,验证了我们基类代码的正确性。

  • 相关阅读:
    kbmMW 5.07.00试用笔记
    在 Andriod/IOS 程序中使用自定义字体
    【转】Delphi 10.3关于相机该注意的细节
    Delphi 10.3终于来了
    REST easy with kbmMW #17 – Database 6 – Existing databases
    【go】1环境搭建go语言介绍
    【ESSD技术解读02】企业级利器,阿里云 NVMe 盘和共享存储
    项目实战总结以及接入UAPM
    RocketMQ 5.0 POP 消费模式探秘
    Cube 技术解读 | 详解「支付宝」全新的卡片技术栈
  • 原文地址:https://www.cnblogs.com/wuhuacong/p/4584307.html
Copyright © 2020-2023  润新知