用户登录
用户注册

分享至

开始你的api:NetApiStarter

  • 作者: 看我名字干啥
  • 来源: 51数据库
  • 2021-11-04

在此之前,写过一篇 给新手的webapi实践 ,获得了很多新人的认可,那时还是基于.net mvc,文档生成还是自己闹洞大开写出来的,经过这两年的时间,netcore的发展已经势不可挡,自己也在不断的学习,公司的项目也转向了netcore。大部分也都是前后分离的架构,后端api开发居多,从中整理了一些东西在这里分享给大家。

源码地址:https://gitee.com/loogn/netapistarter,这是一个基于netcore mvc 3.0的模板项目,如果你使用的netcore 2.x,除了引用不通用外,代码基本是可以复用的。下面介绍一下其中的功能。

登录验证

这里我默认使用了jwt登录验证,因为它足够简单和轻量,在netcore mvc中使用jwt验证非常简单,首先在startup.cs文件中配置服务并启用:
configureservices方法中:

            var jwtsection = configuration.getsection("jwt");
            services.addauthentication(jwtbearerdefaults.authenticationscheme)
                .addjwtbearer(options =>
                {
                    options.tokenvalidationparameters = new tokenvalidationparameters
                    {
                        validateissuer = true,
                        validateaudience = true,
                        validatelifetime = true,
                        validateissuersigningkey = true,
                        validaudience = jwtsection["audience"],
                        validissuer = jwtsection["issuer"],
                        issuersigningkey = new symmetricsecuritykey(encoding.utf8.getbytes(jwtsection["signingkey"]))
                    };
                });

configure方法中,在userouting和useendpoints方法之前:

app.useauthorization();

上面我们使用到了jwt配置块,对应appsettings.json文件中有这样的配置:

{
  "jwt": {
    "signingkey": "1234567812345678",
    "issuer": "netapistarter",
    "audience": "netapistarter"
  }
}

我们再操作两步来实现登录验证,
一、提供一个接口生成jwt,
二、在客户端请求头部加上authorization: bearer {jwt}
我先封装了一个生成jwt的方法

    public static class jwthelper
    {
        public static string writetoken(dictionary<string, string> claimdict, datetime exp)
        {
            var key = new symmetricsecuritykey(encoding.utf8.getbytes(appsettings.instance.jwt.signingkey));
            var creds = new signingcredentials(key, securityalgorithms.hmacsha256);
            var token = new jwtsecuritytoken(
                issuer: appsettings.instance.jwt.issuer,
                audience: appsettings.instance.jwt.audience,
                claims: claimdict.select(x => new claim(x.key, x.value)),
                expires: exp,
                signingcredentials: creds);
            var jwt = new jwtsecuritytokenhandler().writetoken(token);
            return jwt;
        }
}

然后在登录服务中调用

        /// <summary>
        /// 登录,获取jwt
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        public resultobject<loginresponse> login(loginrequest request)
        {
            var user = userdao.getuser(request.account, request.password);
            if (user == null)
            {
                return new resultobject<loginresponse>("用户名或密码错误");
            }
            var dict = new dictionary<string, string>();
            dict.add("userid", user.id.tostring());
            var jwt = jwthelper.writetoken(dict, datetime.now.adddays(7));
            var response = new loginresponse { jwt = jwt };
            return new resultobject<loginresponse>(response);
        }

在controller和action上添加[authorize]和[allowanonymous]两个特性就可以实现登录验证了。

请求响应

这里请求响应的设计依然没有使用restful风格,一是感觉太麻烦,二是真的不太懂(实事求是),所以请求还是以post方式投递json数据,响应当然也是json数据这个没啥异议的。
为啥使用post+json呢,主要是简单,大家都懂,而且规则统一、繁简皆宜,比如什么参数都不需要,就传{},根据id查询文章{articleid:23},或者复杂的查询条件和表单提交{ name:'abc', addr:{provice:'henan', city:'zhengzhou'},tags:['骑马','射箭'] } 等等都可以优雅的传递。
这只是我个人的风格,netcore mvc是支持其他的方式的,选自己喜欢的就行了。
下面的内容还是按照post+json来说。

首先提供请求基类:

    /// <summary>
    /// 登录用户请求的基类
    /// </summary>
    public class loginedrequest
    {
        #region jwt相关用户
        private claimsprincipal _claimsprincipal { get; set; }

        public claimsprincipal getprincipal()
        {
            return _claimsprincipal;
        }
        public void setprincipal(claimsprincipal user)
        {
            _claimsprincipal = user;
        }

        public string getclaimvalue(string name)
        {
            return _claimsprincipal?.findfirst(name)?.value;
        }
        #endregion

        #region 数据库相关用户 (如果有必要的话)
        //不用属性是因为swagger中会显示出来
        private user _user;
        public user getuser()
        {
            return _user;
        }
        public void setuser(user user)
        {
            _user = user;
        }
        #endregion
    }

这个类中说白了就是两个手写属性,一个claimsprincipal用来保存从jwt解析出来的用户,一个user用来保存数据库中完整的用户信息,为啥不直接使用属性呢,上面注释也提到了,不想在api文档中显示出来。这个用户信息是在服务层使用的,而且user不是必须的,比如jwt中的信息够服务层使用,不定义user也是可以的,总之这里的信息是为服务层逻辑服务的。
我们还可以定义其他的基类,比如经常用的分页基类:

    public class pagedrequest : loginedrequest
    {
        public int pageindex { get; set; }
        public int pagesize { get; set; }
    }

根据项目的实际情况还可以定义更多的基类来方便开发。

响应类使用统一的格式,这里直接提供json方便查看:

{
??"result":?{
????"jwt":?"string"
??},
??"success":?true,
??"code":?0,
??"msg":?"错误信息"
}

result是具体的响应对象,如果success为false的话,result一般是null。

actionfilter

mvc本身是一个扩展性极强的框架,层层有拦截,actionfilter就是其中之一,iactionfilter接口有两个方法,一个是onactionexecuted,一个是onactionexecuting,从命名也能看出,就是在action的前后分别执行的方法。我们这里主要重写onactionexecuting方法来做两件事:
一、将登陆信息赋值给请求对象
二、验证请求对象
这里说的请求对象,其类型就是loginedrequest或者loginedrequest的子类,看代码:

    [appservice]
    [attributeusage(attributetargets.class | attributetargets.method, allowmultiple = false, inherited = true)]
    public class myactionfilterattribute : actionfilterattribute
    {
        /// <summary>
        /// 是否验证参数有效性
        /// </summary>
        public bool validparams { get; set; } = true;

        public override void onactionexecuting(actionexecutingcontext context)
        {
            //由于filters是套娃模式,使用以下逻辑保证作用域的覆盖 action > controller > global
            if (context.filters.oftype<myactionfilterattribute>().last() != this)
            {
                return;
            }

            //默认只有一个参数
            var firstparam = context.actionarguments.firstordefault().value;
            if (firstparam != null && firstparam.gettype().isclass)
            {
                //验证参数合法性
                if (validparams)
                {
                    var validationresults = new list<validationresult>();
                    var validationflag = validator.tryvalidateobject(firstparam, new validationcontext(firstparam), validationresults, false);
                    if (!validationflag)
                    {
                        var ro = new resultobject(validationresults.first().errormessage);
                        context.result = new jsonresult(ro);
                        return;
                    }
                }
            }

            var requestparams = firstparam as loginedrequest;
            if (requestparams != null)
            {
                //设置jwt用户
                requestparams.setprincipal(context.httpcontext.user);
                var userid = requestparams.getclaimvalue("userid");
                //如果有必要,可以每次都获取数据库中的用户
                if (!string.isnullorempty(userid))
                {
                    var user = ((userservice)context.httpcontext.requestservices.getservice(typeof(userservice))).singlebyid(long.parse(userid));
                    requestparams.setuser(user);
                }
            }

            base.onactionexecuting(context);
        }
    }

模型验证这块使用的是系统自带的,从上面代码也可以看出,如果请求对象定义为loginedrequest及其子类,每次请求会填充claimsprincipal,如果有必要,可以从数据库中读取user信息填充。
请求经过actionfilter时,模型验证不通过的,直接返回了验证错误信息,通过之后到达action和service时,用户信息已经可以直接使用了。

api文档和日志

api文档首选swagger了,aspnetcore 官方文档也是使用的这个,我这里用的是swashbuckle,首先安装引用

install-package swashbuckle.aspnetcore -version 5.0.0-rc4
定义一个扩展类,方便把swagger注入容器中:

public static class swaggerserviceextensions
    {
        public static iservicecollection addswagger(this iservicecollection services)
        {
            //http://www.51sjk.com/Upload/Articles/1/0/294/294992_20210728132524099.aspnetcore
            services.addswaggergen(c =>
            {
                c.swaggerdoc("v1", new openapiinfo
                {
                    title = "my api",
                    version = "v1"
                });
                c.ignoreobsoleteactions();
                c.ignoreobsoleteproperties();
                c.documentfilter<swaggerdocumentfilter>();
                //自定义类型映射
                c.maptype<byte>(() => new openapischema { type = "byte", example = new openapibyte(0) });
                c.maptype<long>(() => new openapischema { type = "long", example = new openapilong(0l) });
                c.maptype<int>(() => new openapischema { type = "integer", example = new openapiinteger(0) });
                c.maptype<datetime>(() => new openapischema { type = "datetime", example = new openapidatetime(datetimeoffset.now) });

                //xml注释
                foreach (var file in directory.getfiles(appcontext.basedirectory, "*.xml"))
                {
                    c.includexmlcomments(file);
                }

                //authorization的设置
                c.addsecuritydefinition("bearer", new openapisecurityscheme
                {
                    in = parameterlocation.header,
                    description = "请输入验证的jwt。示例:bearer {jwt}",
                    name = "authorization",

                    type = securityschemetype.apikey,
                });
            });
            return services;
        }

        /// <summary>
        /// swagger控制器描述文字
        /// </summary>
        class swaggerdocumentfilter : idocumentfilter
        {
            public void apply(openapidocument swaggerdoc, documentfiltercontext context)
            {
                swaggerdoc.tags = new list<openapitag>
                {
                    new openapitag{ name="user", description="用户相关"},
                    new openapitag{ name="common", description="公共功能"},
                };
            }
        }
    }

主要是验证部分,加上去之后就可以在文档中使用jwt测试了
然后在startup.cs的configureservices方法中
services.addswagger();
configure方法中:

            if (env.isdevelopment())
            {
                app.useswagger();
                app.useswaggerui(options =>
                {
                    options.swaggerendpoint("/swagger/v1/swagger.json", "my api v1");
                    options.docexpansion(docexpansion.none);
                });
            }

这里限制了只有在开发环境才显示api文档,如果是需要外部调用的话,可以不做这个限制。

日志组件使用serilog。
首先也是安装引用
install-package serilog
install-package serilog.aspnetcore
install-package serilog.settings.configuration
install-package serilog.sinks.rollingfile

然后在appsettings.json中添加配置

{
  "serilog": {
    "writeto": [
      { "name": "console" },
      {
        "name": "rollingfile",
        "args": { "pathformat": "logs/{date}.log" }
      }
    ],
    "enrich": [ "fromlogcontext" ],
    "minimumlevel": {
      "default": "debug",
      "override": {
        "microsoft": "warning",
        "system": "warning"
      }
    }
  },
}

更多配置请查看

上述配置会在应用程序根目录的logs文件夹下,每天生成一个命名类似20191129.log的日志文件

最后要修改一下program.cs,代替默认的日志组件

public static ihostbuilder createhostbuilder(string[] args) =>
            host.createdefaultbuilder(args)
                .configurewebhostdefaults(webbuilder =>
                {
                    webbuilder.useconfiguration(new configurationbuilder().setbasepath(environment.currentdirectory).addjsonfile("appsettings.json").build());

                    webbuilder.usestartup<startup>();

                    webbuilder.useserilog((whbcontext, configurelogger) =>
                    {
configurelogger.readfrom.configuration(whbcontext.configuration);
                    });
                });

文件分块上传

文件上传就像登录验证一样常用,哪个应用还不上传个头像啥的,所以我也打算整合到模板项目中,如果是单纯的上传也就没必要说了,这里主要说的是一种大文件上传的解决方法: 分块上传。

分块上传是需要客户端配合的,客户端把一个大文件分好块,一小块一小块的上传,上传完成之后服务端按照顺序合并到一起就是整个文件了。

所以我们先定义分块上传的参数:

string identifier : 文件标识,一个文件的唯一标识,
int chunknumber :当前块所以,我是从1开始的
int chunksize :每块大小,客户端设置的固定值,单位为byte,一般2m左右就可以了
long totalsize:文件总大小,单位为byte
int totalchunks:总块数

这些参数都好理解,在服务端验证和合并文件时需要。

开始的时候我是这样处理的,客户端每上传一块,我会把这块的内容写到一个临时文件中,使用identifier和chunknumber来命名,这样就知道是哪个文件的哪一块了,当上传完最后一块之后,也就是chunknumber==totalchunks的时候,我将所有的分块小文件合并到目标文件,然后返回url。

这个逻辑是没什么问题,只需要一个机制保证合并文件的时候所有块都已上传就可以了,为什么要这样一个机制呢,主要是因为客户端的上传可能是多线程的,而且也不能完全保证http的响应顺序和请求顺序是一样的,所以虽然上传完最后一块才会合并,但是还是需要一个机制判断一下是否所有块都上传完毕,没有上传完还要等待一下(想一想怎么实现!)。

后来在实际上传过程中发现最后一块响应会比较慢,特别是文件很大的时候,这个也好理解,因为最后一块上传会合并文件,所以需要优化一下。

这里就使用到了队列的概念了,我们可以把每次上传的内容都放在队列中,然后使用另一个线程从队列中读取并写入目标文件。在这个场景中blockingcollection是最合适不过的了。
我们定义一个实体类,用于保存入列的数据:

    public class uploadchunkitem
    {
        public byte[] data { get; set; }
        public int chunknumber { get; set; }
        public int chunksize { get; set; }
        public string filepath { get; set; }
    }

然后定义一个队列写入器

public class uploadchunkwriter
    {
        public static uploadchunkwriter instance = new uploadchunkwriter();
        private blockingcollection<uploadchunkitem> _queue;
        private int _writeworkercount = 3;
        private thread _writethread;
        public uploadchunkwriter()
        {
            _queue = new blockingcollection<uploadchunkitem>(500);
            _writethread = new thread(this.write);
        }

        public void write()
        {
            while (true)
            {
                //单线程写入
                //var item = _queue.take();
                //using (var filestream = file.open(item.filepath, filemode.open, fileaccess.write, fileshare.readwrite))
                //{
                //    filestream.position = (item.chunknumber - 1) * item.chunksize;
                //    filestream.write(item.data, 0, item.data.length);
                //    item.data = null;
                //}

                //多线程写入
                task[] tasks = new task[_writeworkercount];
                for (int i = 0; i < _writeworkercount; i++)
                {
                    var item = _queue.take();
                    tasks[i] = task.run(() =>
                     {
                         using (var filestream = file.open(item.filepath, filemode.open, fileaccess.write, fileshare.readwrite))
                         {
                             filestream.position = (item.chunknumber - 1) * item.chunksize;
                             filestream.write(item.data, 0, item.data.length);
                             item.data = null;
                         }
                     });
                }
                task.waitall(tasks);
            }
        }

        public void add(uploadchunkitem item)
        {
            _queue.add(item);
        }

        public void start()
        {
            _writethread.start();
        }
    }

主要是write方法的逻辑,调用_queue.take()方法从队列中获取一项,如果队列中没有数据,这个方法会堵塞当前线程,这也是我们所期望的,获取到数据之后,打开目标文件(在上传第一块的时候会创建),根据chunknumber 和chunksize找到开始写入的位置,然后把本块数据写入。
打开目标文件的时候使用了fileshare.readwrite,表示这个文件可以同时被多个线程读取和写入。

文件上传方法也简单:

        /// <summary>
        /// 分片上传
        /// </summary>
        /// <param name="formfile"></param>
        /// <param name="chunknumber"></param>
        /// <param name="chunksize"></param>
        /// <param name="totalsize"></param>
        /// <param name="identifier"></param>
        /// <param name="totalchunks"></param>
        /// <returns></returns>
        public resultobject<uploadfileresponse> chunkuploadfile(iformfile formfile, int chunknumber, int chunksize, long totalsize,
            string identifier, int totalchunks)
        {
            var appsetting = appsettings.instance;
            #region 验证
            if (formfile == null && formfile.length == 0)
            {
                return new resultobject<uploadfileresponse>("文件不能为空");
            }
            if (formfile.length > appsetting.upload.limitsize)
            {
                return new resultobject<uploadfileresponse>("文件超过了最大限制");
            }
            var ext = path.getextension(formfile.filename).tolower();
            if (!appsetting.upload.allowexts.contains(ext))
            {
                return new resultobject<uploadfileresponse>("文件类型不允许");
            }
            if (chunknumber == 0 || chunksize == 0 || totalsize == 0 || identifier.length == 0 || totalchunks == 0)
            {
                return new resultobject<uploadfileresponse>("参数错误0");
            }
            if (chunknumber > totalchunks)
            {
                return new resultobject<uploadfileresponse>("参数错误1");
            }
            if (totalsize > appsetting.upload.totallimitsize)
            {
                return new resultobject<uploadfileresponse>("参数错误2");
            }
            if (chunknumber < totalchunks && formfile.length != chunksize)
            {
                return new resultobject<uploadfileresponse>("参数错误3");
            }
            if (totalchunks == 1 && formfile.length != totalsize)
            {
                return new resultobject<uploadfileresponse>("参数错误4");
            }
            #endregion

            //写入逻辑
            var now = datetime.now;
            var yy = now.tostring("yyyy");
            var mm = now.tostring("mm");
            var dd = now.tostring("dd");

            var filename = encrypthelper.md5encrypt(identifier) + ext;
            var folder = path.combine(appsetting.upload.uploadpath, yy, mm, dd);
            var filepath = path.combine(folder, filename);

            //线程安全的创建文件
            if (!file.exists(filepath))
            {
                lock (lockobj)
                {
                    if (!file.exists(filepath))
                    {
                        if (!directory.exists(folder))
                        {
                            directory.createdirectory(folder);
                        }
                        file.create(filepath).dispose();
                    }
                }
            }

            var data = new byte[formfile.length];
            formfile.openreadstream().read(data, 0, data.length);

            uploadchunkwriter.instance.add(new uploadchunkitem
            {
                chunknumber = chunknumber,
                chunksize = chunksize,
                data = data,
                filepath = filepath
            });
            if (chunknumber == totalchunks)
            {
                //等等写入完成
                int i = 0;
                while (true)
                {
                    if (i >= 20)
                    {
                        return new resultobject<uploadfileresponse>
                        {
                            success = false,
                            msg = $"上传失败,总大小:{totalsize},实际大小:{new fileinfo(filepath).length}",
                            result = new uploadfileresponse { url = "" }
                        };
                    }
                    if (new fileinfo(filepath).length != totalsize)
                    {
                        thread.sleep(timespan.frommilliseconds(1000));
                        i++;
                    }
                    else
                    {
                        break;
                    }
                }
                var fileurl = $"{appsetting.rooturl}{appsetting.upload.requestpath}/{yy}/{mm}/{dd}/{filename}";
                var response = new uploadfileresponse { url = fileurl };
                return new resultobject<uploadfileresponse>(response);
            }
            else
            {
                return new resultobject<uploadfileresponse>
                {
                    success = true,
                    msg = "uploading...",
                    result = new uploadfileresponse { url = "" }
                };
            }
        }

撇开上面的参数验证,主要逻辑也就是三个,一是创建目标文件,二是分块数据加入队列,三是最后一块的时候要验证文件的完整性(也就是所有的块都上传了,并都写入到了目标文件)

创建目标文件需要保证线程安全,这里使用了双重检查加锁机制,双重检查的优点是避免了不必要的加锁情况。

完整性我只是验证了文件的大小,这只是一种简单的机制,一般是够用了,别忘了我们的接口都是受jwt保护的,包括这里的上传文件。如果要求更高的话,可以让客户端传参整个文件的md5值,然后服务端验证合并之后文件的md5是否和客户端给的一致。

最后要开启写入线程,可以在startup.cs的configure方法中开启:

uploadchunkwriter.instance.start();

经过这样的整改,上传速度溜溜的,最后一块也不用长时间等待啦!

(项目中当然也包含了不分块上传)

其他功能

自从netcore提供了依赖注入,我也习惯了这种写法,不过在构造函数中写一堆注入实在是难看,而且既要声明字段接收,又要写参数赋值,挺麻烦的,于是乎自己写了个小组件,已经用于手头所有的项目,当然也包含在了netapistarter中,不仅解决了属性和字段注入,同时也解决了实现多接口注入的问题,以及一个接口多个实现精准注入的问题,详细说明可查看项目文档autowired.core。

如果你听过mediatr,那么这个功能不需要介绍了,项目中包含一个应用程序级别的事件发布和订阅的功能,具体使用可查看文档appeventservice。

如果你听过automapper,那么这个功能也不需要介绍了,项目中包含一个simplemapper,代码不多功能还行,支持嵌套类、数组、ilist<>、idictionary<,>实体映射在多层数据传输的时候可谓是必不可少的功能,用法嘛就不说了,只有一个map方法太简单了

重中之重

如果你感觉这个项目对你、或者其他人(you or others,没毛病)有稍许帮助,请给个star好吗!

netapistarter仓库地址:https://gitee.com/loogn/netapistarter

软件
前端设计
程序设计
Java相关