整合一個可用於生產環境的靜態服務

江北、發表於2021-01-25
開發過檔案儲存那塊業務的小夥伴或多或少都應該瞭解過諸如: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

 朝看花開滿樹紅,暮看花落樹還空?

 

相關文章