整合一个可用于生产环境的静态服务
开发过文件存储那块业务的小伙伴或多或少都应该了解过诸如:FastDFS、Minio、MongDb GridFS,通过这些第三方组件可以应用于我们的文件存储系统。
之前有用过Minio,性能很高而且部署起来非常简单,有兴趣的同学可以尝试一下。😉
同样,在.Net Core中我们一样可以处理静态文件的读取与写入,我们一般将静态文件都放置于wwwroot文件夹下,但是我们可以自行扩展配置,将对外的URL映射到自定义的目录达到外部对其文件访问的目的。
于是我自己用.Net Core整合了一个文件服务,对外可提供文件的读写操作。特出此文,希望能帮助到大家,仅供参考!😄
我们以微软的官方文档为主,官方文档是最准确,最可靠的。✌️ https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/static-files?view=aspnetcore-5.0
项目的整体结构如下(因为没有涉及过多业务层面的内容,所以分的层也比较简单,但是也易于后期扩展):
如果要使用静态文件的话,我们需要在Configure方法中注册UseStaticFiles中间件,这里话我写了一个扩展方法,如下:
/// <summary> /// 使用静态文件 /// </summary> public static class StaticFilesMildd { public static void UseStaticFilesMildd(this IApplicationBuilder app) { if (app == null) throw new ArgumentNullException(nameof(app)); var staticfile = new StaticFileOptions(); staticfile.ServeUnknownFileTypes = true; var provider = new FileExtensionContentTypeProvider(); #region 手动设置对应的 MIME TYPE =>下载 provider.Mappings[".log"] = "application/x-msdownload"; provider.Mappings[".xls"] = "application/x-msdownload"; provider.Mappings[".doc"] = "application/x-msdownload"; provider.Mappings[".pdf"] = "application/x-msdownload"; provider.Mappings[".docx"] = "application/x-msdownload"; provider.Mappings[".xlsx"] = "application/x-msdownload"; provider.Mappings[".ppt"] = "application/x-msdownload"; provider.Mappings[".pptx"] = "application/x-msdownload"; provider.Mappings[".zip"] = "application/x-msdownload"; provider.Mappings[".rar"] = "application/x-msdownload"; provider.Mappings[".dwg"] = "application/x-msdownload"; #endregion staticfile.ContentTypeProvider = provider; //app.UseStaticFiles(staticfile); // 使用默认文件夹 wwwroot 仅仅使wwwroot对外可见 app.UseStaticFiles(new StaticFileOptions() {
//这里的路径写部署的主机的某个文件夹的路径 FileProvider = new PhysicalFileProvider(@"/data/Files"), }); } }
这里面说一下FileExtensionContentTypeProvider类,这个类里面包含了381种文件的类型。我们可以通过这个类对其文件类型进行删除或替换,比如上面我就把这些文件的MIME 内容类型
给替换掉了,当客户端访问的时候就都为下载。假设我们上传的是一张jpg图片,当我们用其URL进行访问时,则可以在浏览器上直接显示,因为其Content-Type是image/jpeg。如果设置
文件的MIME内容类型是application/x-msdownload这种,那么就会下载。归根结底是Response-Header里面的Content-Type指示浏览器这是什么类型,而不是通过网址后缀去判断的
我们还需要对外提供的上传文件的接口,主要代码如下,Controller层的代码我就不贴出来了,都很简单😊
抽象接口层主要代码:
public interface IUploadBusiness { Task<string> uploadFile(FileDataBase64 file); Task<List<string>> uploadFile(FormData data); Task<string> uploadFile(FileDataStream file); Task<List<string>> uploadFile(FormDataStream data); Task<string> uploadFile(FileDataByte file); Task<List<string>> uploadFile(FormDataByte data); }
业务实现层主要代码:
public class UploadBusiness : IUploadBusiness { private readonly FileHelper _helper; private readonly UtilConvert _utilConvert; public UploadBusiness(FileHelper helper, UtilConvert utilConvert) { _helper = helper; _utilConvert = utilConvert; } /// <summary> /// 上传单个文件 /// </summary> /// Base64 /// <param name="files"></param> /// <returns></returns> public async Task<string> uploadFile(FileDataBase64 file) { return await _helper.uploadFileHelper(file, fileType.base64); } /// <summary> /// 上传多个文件 /// </summary> /// Base64 /// <param name="files"></param> /// <returns></returns> public async Task<List<string>> uploadFile(FormData data) { List<string> picStrArray = new(); foreach (var item in data.files) { picStrArray.Add(await _helper.uploadFileHelper(new { suffix = item.suffix, FileByBase64 = item.FileByBase64 }, fileType.base64)); } return picStrArray; } /// <summary> /// 上传单个文件 /// </summary> /// Stream /// <param name="files"></param> /// <returns></returns> public async Task<string> uploadFile(FileDataStream file) { return await _helper.uploadFileHelper(new { suffix = file.suffix, fileStream = file.FileByStream.OpenReadStream() }, fileType.stream); } /// <summary> /// 上传多个文件 /// </summary> /// Stream /// <param name="files"></param> /// <returns></returns> public async Task<List<string>> uploadFile(FormDataStream data) { List<string> picStrArray = new(); foreach (var item in data.files) { picStrArray.Add(await _helper.uploadFileHelper(new { suffix = item.suffix, fileStream = item.FileByStream.OpenReadStream() }, fileType.stream)); } return picStrArray; } /// <summary> /// 上传单个文件 /// </summary> /// Byte /// <param name="files"></param> /// <returns></returns> public async Task<string> uploadFile(FileDataByte file) { FileDataStream fileData = new(); var fileByte = await _utilConvert.StringByByte(file.FileByByte); return await _helper.uploadFileHelper(new { suffix = file.suffix, fileStream = await _utilConvert.BytesToStream(fileByte) }, fileType.stream); } /// <summary> /// 上传多个文件 /// </summary> /// Byte /// <param name="files"></param> /// <returns></returns> public async Task<List<string>> uploadFile(FormDataByte data) { List<string> picStrArray = new(); foreach (var item in data.files) { var fileByte = await _utilConvert.StringByByte(item.FileByByte); picStrArray.Add(await _helper.uploadFileHelper(new { suffix = item.suffix, fileStream = await _utilConvert.BytesToStream(fileByte) }, fileType.stream)); } return picStrArray; } }
统一的上传公共方法层主要代码:
public class FileHelper { private readonly IHostingEnvironment _hostingEnvironment; private readonly IConfiguration _configuration; public FileHelper(IHostingEnvironment hostingEnvironment, IConfiguration configuration) { _hostingEnvironment = hostingEnvironment; _configuration = configuration; } public async Task<string> uploadFileHelper(dynamic data, fileType fileType) { string path = string.Empty; var picUrl = string.Empty; string Mainpath = string.Empty; if (CommonClass.suffixList.Contains(data.suffix.ToLower())) Mainpath = "/Pictures/" + DateTime.Now.ToString("yyyy-MM-dd") + "/"; else Mainpath = "/OtherFiles/" + DateTime.Now.ToString("yyyy-MM-dd") + "/"; string FileName = DateTime.Now.ToString("yyyyMMddHHmmssfff"); string FilePath = $@"/data/Files" + Mainpath; string type = data.suffix; DirectoryInfo di = new DirectoryInfo(FilePath); path = Mainpath + FileName + type; picUrl = $"{_configuration["machine-ip"]}{path}"; if (!di.Exists) { di.Create(); } if (fileType == fileType.base64) { byte[] arr = Convert.FromBase64String(data.FileByBase64); using (Stream stream = new MemoryStream(arr)) { using (FileStream fs = System.IO.File.Create(FilePath + FileName + type)) { // 复制文件 stream.CopyTo(fs); // 清空缓冲区数据 fs.Flush(); } } } else { using (FileStream fs = System.IO.File.Create(FilePath + FileName + type)) { // 复制文件 data.fileStream.CopyTo(fs); // 清空缓冲区数据 fs.Flush(); } } return picUrl; } }
这里说一下,主机ip我写在了配置文件中,每上传一个文件都对其进行路径拼接。可以上传Base64,Stream或Byte都行。😊
只有读取、写入只是完成了一部分,我们要管控起我们的服务,鉴权、日志、限流、健康检查、备份、集群这些都要需要加入进来。
授权与鉴权
鉴权是指对我们文件上传的接口进行鉴权,校验其令牌的合法性。授权的话就很简单,只需在控制器或方法上加一个特性[Authorize]即可
这里的话我使用JWT,它可以做到跨服务器验证,只要密钥和算法相同,不同服务器程序生成的Token可以互相验证。如果是微服务架构的话需要介入基于 OpenID Connect 和 OAuth 2.0 认证框架IdentityServer4.
主要代码如下:
//添加jwt验证: .AddJwtBearer(options => { options.SaveToken = true; options.RequireHttpsMetadata = false; options.TokenValidationParameters = new TokenValidationParameters() { ValidateIssuer = true, ClockSkew = TimeSpan.FromSeconds(15), ValidateAudience = true, ValidAudience = "Static-Service", ValidIssuer = "localhost", ValidateIssuerSigningKey = true,//是否验证SecurityKey IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JWT:key"]))//拿到SecurityKey }; options.Events = new JwtBearerEvents { //此处为权限验证失败后触发的事件 OnChallenge = context => { context.HandleResponse(); //自定义自己想要返回的数据结果 var payload = JsonConvert.SerializeObject(new { code = 401, success = false, msg = "很抱歉,您无权访问该接口。" }); //自定义返回的数据类型 context.Response.ContentType = "application/json"; context.Response.StatusCode = StatusCodes.Status200OK; //输出Json数据结果 context.Response.WriteAsync(payload); return Task.FromResult(0); } }; });
SecurityKey不能太短了,要在16位以上。这里当鉴权不成功时我就自定义返回响应的数据,以供前端好拿到数据,不然就会返回401Unauthorized,这样不太友好。
如果项目有网关的加入,那么网关就作为了鉴权的入口,如下:
日志
日志的记录我这里使用的是Serilog,同时利用Filter将每次请求与异常都写入了DataBase,借助Elasticsearch和Kibana可以快速查看我们的日志信息。
具体关于Serilog的引入我不做太多介绍,请看此篇文章:https://www.cnblogs.com/zhangnever/p/12459399.html
DataBase中的日志表,如下:
CREATE TABLE `system_log` ( `id` VARCHAR(36) NOT NULL COMMENT '主键', `ip_address` VARCHAR(100) NOT NULL COMMENT '请求ip', `oper_type` VARCHAR(50) NOT NULL COMMENT '操作类型:查看、新增、修改、删除、导入、导出', `oper_describe` VARCHAR(500) NOT NULL COMMENT '操作详情', `level` VARCHAR(50) NOT NULL COMMENT '事件', `exception` VARCHAR(500) NULL DEFAULT NULL COMMENT 'ErrorMessage', `create_op_id` VARCHAR(36) NOT NULL COMMENT '创建人id', `create_op_name` VARCHAR(50) NOT NULL COMMENT '创建人', `create_op_date` DATETIME NOT NULL COMMENT '创建时间', `edit_op_id` VARCHAR(36) NULL DEFAULT NULL COMMENT '修改人id', `edit_op_name` VARCHAR(50) NULL DEFAULT NULL COMMENT '修改人', `edit_op_date` DATETIME NULL DEFAULT NULL COMMENT '修改时间', PRIMARY KEY (`id`) ) COMMENT='系统日志' COLLATE='utf8_general_ci' ENGINE=InnoDB ;
如果需要将日志写入ElasticSearch需要安装包Serilog.Sinks.Elasticsearch
主要代码:
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://localhost:9200/")) { AutoRegisterTemplate = true, ModifyConnectionSettings = connectionSettings => {
//访问的用户名与密码 connectionSettings.BasicAuthentication("username", "password"); return connectionSettings; } })
在Kibana中进行查看:
另外对于异常信息,我这里还加入了邮件发送的功能:
//... ...
var apiUrl = _accessor.HttpContext.Request.Path.ToString(); _context.db.StringIncrement(apiUrl); var result = _context.db.StringGet(apiUrl); //Content mailMessage.Body = $"【Static-Service】当前请求{apiUrl}有异常,异常信息为{exception}。该API已累计异常了{result}次!";
//... ...
限流
对于请求的限流我们可以使用AspNetCoreRateLimit,它是根据IP进行限流,在管道中进行拦截,
源码在此:https://github.com/stefanprodan/AspNetCoreRateLimit
这里的话需要引入两个包:AspNetCoreRateLimit与Microsoft.Extensions.Caching.Redis.
对于不同的Api可配置不同的限流规则,如下:
//... ...
//api规则 "GeneralRules": [ { "Endpoint": "*:/api/*", "Period": "1m", "Limit": 500 }, { "Endpoint": "*/api/*", "Period": "1s", "Limit": 3 }, { "Endpoint": "*/api/*", "Period": "1m", "Limit": 30 }, { "Endpoint": "*/api/*", "Period": "12h", "Limit": 500 } ]
其它代码我就不罗列出来了,感兴趣的同学可以把我的代码down下来看看。如果是微服务项目的话,限流就在网关(Ocelot)处理了,还有就是对外暴露一个接口用作健康检查。
文件备份
对于文件的备份我采用rsync+inotify的方式,inotity用来监控文件或目录的变化,rsync用来远程数据的同步。
我这里有两台服务器,分别是客户端访问的服务器(192.168.2.121)和要同步的远程服务器(192.168.2.122)
一、首先在要同步的远程服务器上做如下操作:
1.安装工具(rsync):yum -y install xinetd rsync
安装完成之后可查看版本看其是否安装成功:rsync --version
2.设置与客户端服务器同步的目录,配置文件在etc/rsyncd.conf中.
[backup] path =/data/Files #同步的目录 comment = Rsync share test auth users = root #用户名 read only = no #只读设为no hosts allow = 192.168.2.121 #客户端服务器的ip hosts deny = *
3.设置访问的用户名与密码,在/etc/rsync_pass中配置,如下:
root:******
4.重启服务 service xinetd restart
5.检测873端口是否已启动 netstat -nultp
二、客户端访问的服务器上做如下配置:
1.安装inotify-toolwget
链接:http://downloads.sourceforge.net/project/inotify-tools/inotify-tools/3.13/inotify-tools-3.13.tar.gz
解压:tar -zxvf inotify-tools-3.13.tar.gz
进入inotify-tools-3.13目录,依次执行 ./configure,make&make install
2.安装工具(rsync):yum -y install xinetd rsync
安装完成之后可查看版本看其是否安装成功:rsync --version
3.设置要同步过去的远程服务器的密码
在/etc/rsync_pass文本中写入即可
4.设置脚本,如下: #!/bin/bash inotify_rsync_fun () { dir=`echo $1 | awk -F"," '{print $1}'` ip=`echo $1 | awk -F"," '{print $2}'` des=`echo $1 | awk -F"," '{print $3}'` user=`echo $1 | awk -F"," '{print $4}'` inotifywait -mr --timefmt '%d/%m/%y %H:%M' --format '%T %w %f' -e modify,delete,create,attrib ${dir} | while read DATE TIME DIR FILE do FILECHAGE=${DIR}${FILE} /usr/bin/rsync -av --progress --delete --password-file=/etc/rsync_pass ${dir} ${user}@${ip}::${des} && echo "At ${TIME} on ${DATE}, <br> file $FILECHAGE was backed up via rsync" >> /var/log/rsyncd.log done } count=1 # localdir,host,rsync_module,user of rsync_module, sync1="/data/Files/,192.168.2.122,backup,root" ############################################################# #main i=0 while [ ${i} -lt ${count} ] do i=`expr ${i} + 1` tmp="sync"$i eval "sync=\$$tmp" inotify_rsync_fun "$sync" & done
部署
部署的话我先执行dotnet *.dll,然后用nginx做了一个代理转发.
1.先查看是否安装了dotnet环境:dotnet --info
2.将发布好的项目Copy到服务器上,切入到项目根目录执行:nohup dotnet Static-Application.dll --urls="http://*:85" --ip="127.0.0.1" --port=85 >output 2>&1 &
这里需要说一下在命令中加入nohup可以在你退出帐户/关闭终端之后继续运行相应的进程.
3.配置nginx,在/etc/nginx/conf.d文件中新增static-nginx.conf文件。配置的语句如下: server { listen 86; server_name localhost; proxy_set_header X-Real-IP $remote_addr; location /index.html { proxy_pass http://localhost:85/swagger/index.html; } location /api/{ proxy_pass http://localhost:85; } location /{ proxy_pass http://localhost:85/swagger/; } location /swagger/{ proxy_pass http://localhost:85; } }
4.检查语句是否正确:nginx -t 重启nginx:nginx -s reload
打开谷歌浏览器查看一下
GoAccess
这里使用了nginx,可以用实时网络日志分析器GoAccess来统计和展示日志信息。
1.安装GoAccess,可看官网:https://goaccess.io/download
2.配置nginx,可在浏览器端访问. server { listen 9000; server_name localhost; location /report.html { alias /home/zopen/nginx/html/report.html; } }
3.运行GoAccess关联到nginx的日志 goaccess /var/log/nginx/access.log -o /home/zopen/nginx/html/report.html --real-time-html --time-format='%H:%M:%S' --date-format='%d/%b/%Y' --log-format=COMBINED
在谷歌浏览器中打开查看
文中可能有疏漏,如有不当,望谅解!😊
但凡可以帮到各位一点点,那就没白忙活.
代码我已上传至我的GitHub:https://github.com/Davenever/static-service.git
朝看花开满树红,暮看花落树还空😊