UWInfo Blog
發表新文章
[Join] | [忘記密碼] | [Login]
搜尋

搜尋意見
文章分類-#Author#
[所有文章分類]
所有文章分類
  • ASP.NET (48)
  • ASP.NET2.0 (15)
  • ASP.NET4.0 (34)
  • JavaScript (49)
  • jQuery (26)
  • FireFox (4)
  • UW系統設定 (3)
  • SQL (39)
  • SQL 2008 (25)
  • mirror (4)
  • SVN (4)
  • IE (9)
  • IIS (20)
  • IIS6 (1)
  • 閒聊 (7)
  • W3C (6)
  • 作業系統 (9)
  • C# (24)
  • CSS (12)
  • FileServer (1)
  • HTML 5 (11)
  • CKEditor (3)
  • UW.dll (13)
  • Visual Studio (16)
  • Browser (8)
  • SEO (1)
  • Google Apps (3)
  • 網站輔助系統 (4)
  • DNS (5)
  • SMTP (4)
  • 網管 (11)
  • 社群API (3)
  • SSL (4)
  • App_Inventor (1)
  • URLRewrite (2)
  • 開發工具 (6)
  • JSON (1)
  • Excel2007 (1)
  • 試題 (3)
  • LINQ (1)
  • bootstrap (0)
  • Vue (3)
  • IIS7 (3)
  • foodpanda (2)
  • 編碼 (2)
  • 資安 (3)
  • Sourcetree (1)
  • MAUI (1)
  • CMD (1)
  • my sql (1)
最新回應
  • Newtonsoft.Json.JsonConvert.DeserializeObject 失敗的情況
    test...more
  • dotnet ef dbcontext scaffold
    ...more
  • [ASP.NET] 利用 aspnet_regiis 加密 web.config
    ...more
  • IIS ARR (reverse proxy) 服務安裝
    ...more
  • [錯誤訊息] 請加入 ScriptResourceMapping 命名的 jquery (區分大小寫)
    ...more
  • 用 Javascript 跨網頁讀取 cookie (Cookie cross page, path of cookie)
    ...more
  • 線上客服 - MSN
    本人信箱被盜用以致資料外洩,是否可以請貴平台予以協助刪除該信箱之使用謝謝囉...more
  • 插入文字到游標或選取處
    aaaaa...more
  • IIS 配合 AD (Active Directory) 認証, 使用 .Net 6.0
    太感謝你了~~~你救了我被windows 認證卡了好幾天QQ...more
  • PostgreSQL 的 monitor trigger
    FOR EACH ROW 可能要改為 FOR EACH STATEMENT ...more
標籤
  • lazy
  • List
  • -7099
  • c
  • datagrid
  • 8
  • datatable
  • 68
  • -8154
  • -9768
  • -6396
  • restoring
  • 60
  • .stop
  • -9328
  • 404.17
  • CK
  • -7984
  • tpbase
  • writefile
  • 56
  • 88
  • Rewrite
  • VPN
  • -2882
  • youtube
  • wsdl
  • nexttick
  • Deadlock
  • sqlbuilder
  • sql2008
  • for
  • sqlDate
  • sqlcmd
  • sa
  • 一致性
  • sqlite
  • -5304
  • timeout
  • -2846
  • image,
  • appcmd
  • -5846
  • -6734
  • Linq
  • Forti
  • -2755
  • formhelper
  • formdata
  • 1
頁數 1 / 5 下一頁
搜尋 44 結果:
IIS ARR (reverse proxy) 服務安裝
IIS 如果要使用 reverse proxy server 服務,其實網路上已經有很多文章可以參考
這篇文章只是記錄一下安裝上要注意的事

過去安裝 IIS 套件 可以透過 Web Platform Installer 搜尋下載
但現在 IIS 的 Web Platform Installer 已經不讓人搜尋下載可安裝的套件
所以要直接去微軟網站找相關套件 可以用 IIS ARR 搜尋
https://www.iis.net/downloads/microsoft/application-request-routing
下載 requestRouter_amd64.msi 安裝這個 (3.0版 2021 年以後就沒有更新了)
安裝前,IIS也要預先安裝 URLRewrite 2 套件

安裝很簡單,msi 安裝後,IIS重啟就可以看到
IIS 的主機設定,可以看到 "Application Reuest Routing Cache"  --> 點進後右邊有 Server Proxy Settings
proxy 的設定有一些地方要注意一下,避免未來採到雷

首先 當然先開啟 Enable proxy,下面針對一些要注意的屬性說明一下

1. Time-out : 預設120秒,如果你後端的站台有一些操作可能超過兩分鐘(例如處理報表),這個就調長一點
2. Reverse rewrite host in response header: 這個勾勾預設是開的,他的好意是同站台redirect(302) 到其他網頁,可以覆蓋
    host 讓 client端能跑到正常的網址。但如果你是 redirect 到其他站台,建議把它關掉,不然後端網站如果下
    redirect (302) 到別的站台,他會主動把 redirect網址 host 改為本站 (被雷過,所以要特別記下來)
3. Include TCP port from client IP:  這是一個 X-Forwarded-For 設定,預設是打開,這樣後端主機抓 client 來源 IP就會類似
    "112.121.100.100:443" ,但後端網站在抓 client端IP通常不會管 port number,因此就會造成比對 IP 發生錯誤
    所以建議還是把它關閉
4. Enable disk cache: 預設是勾勾打開,如果後端是靜態網站,例如圖片server,這個打開沒有問題,但如果後端網站是動態網站
    那還是關掉

 
More...
darren, 2025/1/10 上午 11:01:38
Entity Framework 用起來和 Daper 越來越像了
Entity Framework 提供了 ExecuteSqlRawAsync 和 FromSqlRaw  之後,可以和 Dapper 非常類似的用法。

我們在用 Dapper 時。最常用的就是 sql command 加上一個物件做為參數,就可以執行 CRUD 的動作。

其實用 Entity Framework 的 ExecuteSqlRawAsync 和 FromSqlRaw 也可以逹到幾乎一樣的效果。

ExecuteSqlRawAsync 和 FromSqlRaw 接受的參數是 object array (其實是 Microsoft.Data.SqlClient.SqlParameter 的 array)

所以我們先做一個 Object to Microsoft.Data.SqlClient.SqlParameter Array 的擴充, 可參考: https://gist.github.com/aliozgur/75182b2e9b0a58b83443

不過很奇怪的是,原作者提供的擴充轉出來的會是 System.Data.SqlClient.SqlParameter  Array 無法直接使用於 ExecuteSqlRawAsync 和 FromSqlRaw,所以要稍微改一下,把 using System.Data.SqlClient; 改為 using Microsoft.Data.SqlClient; 即可:



另外,我們再自行 對 DbContext 做一個擴充如下:

        /// <summary>
        /// 會把物件 Parameter 的各 Property 帶入 SQL 中.
        /// </summary>
        /// <param name="dbct"></param>
        /// <param name="sql"></param>
        /// <param name="parameters"></param>
        /// <returns></returns>
        public static async Task<int> ExecuteParameterSqlAsync(this DbContext dbct, string sql, object parameters)
        {
            return await dbct.Database.ExecuteSqlRawAsync(sql, parameters.ToSqlParamsArray());
        }



最後的結果可以做到以下的效果: (res3 和 res4 是配合 FormattableString 的範例 )

 
範例程式碼:
        public async Task<object> EfTest()
        {
            var dbct = Ds.NewContext.GvContext;
            var insertSql = @"Insert into ProfileEvent(JobId,[Name],GvShoplineId,LiShoplineId,Phone,Email,LineId,GaClientId)
            Values(@JobId, @Name, null , null , null , @Email, @GaClientId , @GaClientId )";

            //執行 SQL 的原生寫法
            await dbct.Database.ExecuteSqlRawAsync(insertSql, new object[]
            {
                new SqlParameter("@JobId", 10),
                new SqlParameter("@Email", "XX'TT"),
                new SqlParameter("@Name", "AA'BB"),
                new SqlParameter("@GaClientId", "GaClientId"),
            });

            //執行 SQL 的擴充寫法
            await dbct.ExecuteParameterSqlAsync(insertSql, new
            {
                JobId = 10,
                Email = "XX'TT",
                Name = "AA'BB",
                GaClientId = "GaClientId"
            });

            //關於查詢
            string name = "%'B%";
            //原生寫法
            var res1 = await dbct.ProfileEvents.FromSqlRaw("Select top 100 * from ProfileEvent where Name like @name",
                new object[]
                    {
                        new SqlParameter("@name", "%'B%")
                    })
                .ToListAsync();

            //擴充, object to SqlParameter array
            var res2 = await dbct.ProfileEvents.FromSqlRaw("Select top 100 * from ProfileEvent where Name like @name",
                new { name }.ToSqlParamsArray())
                .ToListAsync();

            var res3 = await dbct.ProfileEvents.FromSql($"Select top 100 * from ProfileEvent where Name like {name}")
                .ToListAsync();

            var res4 = await dbct.ProfileEvents.FromSqlInterpolated($"Select top 100 * from ProfileEvent where Name like {name}")
                .ToListAsync();

            return new { res1, res2, res3, res4 };
        }

 
More...
Bike, 2023/5/6 下午 05:37:19
mssql 開啟127.0.0.1
 開啟TCPIP
 

重啟SQL Server
 
注意連線可能有port要記得設定
 



 
More...
Doug, 2023/5/3 下午 12:44:41
使用SemaphoreSlim來對非同步情況下使用Lock
由於在使用Await時,系統的Thread並不保證會與該function使用同一個Thread。
因此使用lock將單一Thread鎖住會導致出現 'Cannot await in the body of a lock statement'的錯誤

將Lock物件改成使用SemaphoreSlim,於function起始與結束加入WaitAsync與Release即可達到Lock的效果。

SemaphoreSlim完整介紹 : 用 SemaphoreSlim 來做 async/await 的鎖定
使用如下圖↓

 
 
More...
梨子, 2023/3/23 下午 03:00:41
Sourcetree 重新安裝
Sourcetree重新安裝(完整清除舊資料)

1. 移除電腦上的Sourcetree軟體
2. 備份以下檔案後,然後刪除:
路徑:C:\Users\<UserName>\AppData\Local  刪除 SourceTree 資料夾
路徑:C:\Users\<UserName>\AppData\Local\Atlassian 刪除 SourceTree 資料夾
路徑:C:\Users\<UserName>\AppData\Roaming\Atlassian 刪除 SourceTree 資料夾
3. 重新下載Sourcetree軟體進行安裝

參考來源:
https://community.atlassian.com/t5/Sourcetree-questions/How-do-I-fully-remove-SourceTree-so-that-I-can-do-a-quot-fresh/qaq-p/1448403
More...
choco, 2023/2/2 上午 08:13:27
資料存取架構比較 Entity Framework, Data Table, Dapper
比較方式: 對相同的資料表讀取 1000 次,來比較速度。
資料庫為 MS SQL

這裡做了 4 個 Case:

Case 1: 
使用 Entity Framework,DbContext 為每次 new 一個。

Case 2:
使用 Entity Framework,使用同一個 DbContext。

Case 3:
用 DAO,轉為 Data Table 的格式 (使用 DtFromSql)。

Case 4:
使用 Dapper。

專案啟動時的記憶體使用量約為:  123 MB



測試一:
第一次讀取
Case 1, 時間: 3673.0303 ms, 記憶體: 566 MB

Case 2, 時間:  3889.9379 ms, 記憶體: 304 MB  (這個時間有時會測試到 3000 ms)

Case 3, 時間:  1665.7748 ms, 記憶體: 205 MB

Case 4, 時間:  1639.7211 ms, 記憶體: 193 MB

讀取五次
Case 1, 時間: 1984.6395, 記憶體: 544MB (有發生 GC)


Case 2, 時間:  2367.9792 ms, 記憶體: 627 MB (沒有發生 GC)


Case 3, 時間:  1395.1132 ms, 記憶體: 280 MB (有發生 GC)



Case 4, 時間:  1531.3555 ms, 記憶體: 271 MB


結果: Dapper 看來完勝,速度上和直接使用 DAO 轉進 Data Table 的速度相近。但 Dapper 傳回的結果已經轉換為物件 List, 操作上會更為方便。

Entity Framework 一如預期的較慢,且花費較多的記憶體。但和想像中最大的差異是,使用同一個 DbContext 比 new 1000 個 DbContext  的速度還要慢, 花費的記憶體在 GC 之後,差異不大。

所以不一定要用 DI 注入 DbContext, 在各 function 中一直傳遞 DbContext. 在 Static 的 function 中,可以安心的 new 一個 DbContext 出來使用吧,只是要記得用 using 哦,否則可能會有 connection 沒有關閉的問題。


 
More...
Bike, 2022/7/18 上午 09:46:34
.Net Framework 的檔案檢查機制 Web API 和 MVC 5
我們的目標是在啟動時,掛一個 global filter 來檢查上傳的檔案附檔名,避免上傳未預期的可執行檔。使用 global filter 可以防止程式設計師忘了做檔案檢查,發生危險。我們目前用到的 .Net Framework 有兩個類別,一個是 System.Web.Http.ApiController,另一個是 System.Web.Mvc.Controller,兩個要分開處理。

1. Web API (System.Web.Http.ApiController)
.Net Framework 的 Web API 2 在接收檔案時,必需使用 Request.Content.ReadAsMultipartAsync 把上傳的資料存入 MultipartMemoryStreamProvider 之後再來處理。
範例如下: 
    public class UploadController : ApiController
    {
        public async Task<object> PostFormData()
        {
            var provider = new MultipartMemoryStreamProvider();

            if (! Request.Content.IsMimeMultipartContent())
            {
                return "no file";
            }

            //要注意這裡的 await
            await Request.Content.ReadAsMultipartAsync(provider);

            foreach (var content in provider.Contents)
            {
                if (content.Headers.ContentDisposition.FileName != null)
                {
                    string localFilename = content.Headers.ContentDisposition.FileName.Replace("\"", "");
                    System.IO.Directory.CreateDirectory(HttpContext.Current.Server.MapPath(@"~/App_Data/Temp/"));
                    string filename = HttpContext.Current.Server.MapPath(@"~/App_Data/Temp/" + localFilename);
                    if (System.IO.File.Exists(filename))
                    {
                        System.IO.File.Delete(filename);
                    }

                    using (var fileStream = new FileStream(filename, FileMode.Create, FileAccess.Write))
                    {
                        var contentStream = await content.ReadAsStreamAsync();
                        await contentStream.CopyToAsync(fileStream);
                        Trace.WriteLine("Save To" + filename);
                    }
                }
            }
            return "OK";
        }
    }


程式碼中有個地方我沒有注意到。我一開始是用 Request.Content.ReadAsMultipartAsync(provider); 把資料讀進 provider,但前方沒有加上 await,造成有時讀不到檔案的情況,再次提醒自己 Async 和 Await 的關係。

直覺的想法是在 filter 中執行一樣的程式碼,不存檔,只要抓出檔名來檢查即可。但這時遇到一個問題 Request.Content.ReadAsMultipartAsync 不能執行兩次,Google 了一陣子,也沒找不到類似 seek(0) 的操作。所以只好自己寫一個暫存的機製。

這裡決定使用 System.Web.HttpContext.Current.Items 來暫存 provider,最後的 OnActionExecutingAsync 結果如下:

        /// <summary>
        /// 檔案檢查
        /// </summary>
        /// <param name="actionContext"></param>
        /// <param name="cancellationToken"></param>
        /// <returns></returns>
        public override async Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
        {
            Trace.WriteLine("ApiCheckFile OnActionExecutingAsync");

            var request = actionContext.Request;
            if (!request.Content.IsMimeMultipartContent())
            {
                return;
            }

            var provider = new MultipartMemoryStreamProvider();
            await request.Content.ReadAsMultipartAsync(provider);

            //把 provider 存入 System.Web.HttpContext.Current.Items 之中,以便在 controller 中再度使用
            System.Web.HttpContext.Current.Items["MimeMultipartContentProvider"] = provider;

            foreach (var content in provider.Contents)
            {
                if (content.Headers.ContentDisposition.FileName != null)
                {
                    var filename = content.Headers.ContentDisposition.FileName.Replace("\"", "");
                    Trace.WriteLine(filename);

                    var ext = System.IO.Path.GetExtension(filename);
                    if (!".jpg,.jpeg,.png".Contains(ext.ToLower()))
                    {
                        throw new Exception("file format error.");
                    }
                }
            }

            return;
        }


而 controller 中的 Action 則改為以下的版本;本版本中,除了把上傳的檔案存檔以外,還有讀取 form data 中其它欄位的範例:

        public async Task<object> PostFormData()
        {
            //改由 HttpContext.Current.Items 中,讀取資料。
            MultipartMemoryStreamProvider provider = (MultipartMemoryStreamProvider)System.Web.HttpContext.Current.Items["MimeMultipartContentProvider"];

            //如果沒有經過 filter,provider會是 null, 這時就要直接由 Request.Content 讀入 provider
            if (provider == null)
            {
                provider = new MultipartMemoryStreamProvider();
                Request.Content.ReadAsMultipartAsync(provider);
            }

            foreach (var content in provider.Contents)
            {
                if (content.Headers.ContentDisposition.FileName != null)
                {
                    string localFilename = content.Headers.ContentDisposition.FileName.Replace("\"", "");
                    Trace.WriteLine("FileName: " + localFilename);
                    Trace.WriteLine("FileName: " + @"~/App_Data/Temp/" + localFilename);
                    System.IO.Directory.CreateDirectory(HttpContext.Current.Server.MapPath(@"~/App_Data/Temp/"));
                    string filename = HttpContext.Current.Server.MapPath(@"~/App_Data/Temp/" + localFilename);
                    if (System.IO.File.Exists(filename))
                    {
                        System.IO.File.Delete(filename);
                    }

                    using (var fileStream = new FileStream(filename, FileMode.Create, FileAccess.Write))
                    {
                        var contentStream = await content.ReadAsStreamAsync();
                        await contentStream.CopyToAsync(fileStream);
                        Trace.WriteLine("Save To" + filename);
                    }
                }
                else
                {
                    var contentStream = await content.ReadAsStreamAsync();
                    var reader = new System.IO.StreamReader(contentStream);
                    var data = reader.ReadToEnd();
                    Trace.WriteLine("data: " + data);
                }
            }
            return "OK";
        }


以上是展示 Web Api 的 Filter。

如果是 MVC 的 Controller,就簡單多了,直接讀取 actionContext.HttpContext.Request.Files 裡面各檔案的 FileName 即可。不過也遇到了一個和直覺不同的地方,對 actionContext.HttpContext.Request.Files 居然不能用 foreach 請見下方註解掉的地方。也許是我的用法有問題,如果有人找到可以使用的方法也麻煩告知。

        public override void OnActionExecuting(ActionExecutingContext actionContext)
        {
            Debug.WriteLine("MvcCheckFileFilter OnActionExecuting");
            if (actionContext.HttpContext.Request.Files.Count > 0)
            {
                for (int i = 0; i < actionContext.HttpContext.Request.Files.Count; i++)
                {
                    System.Web.HttpPostedFileBase file = actionContext.HttpContext.Request.Files[i];
                    if (System.IO.Path.GetExtension(file.FileName) != ".jpg")
                    {
                        throw new Exception("file format error.");
                    }
                    Debug.WriteLine(i + "MvcCheckFileFilter OnActionExecuting File type: " + file.FileName.ToString());
                }
            }

            //以下寫法會發生錯誤: 無法將類型 'System.String' 的物件轉換為類型 'System.Web.HttpPostedFileBase'。
            //foreach (HttpPostedFileBase file in actionContext.HttpContext.Request.Files)
            //{
            //    if (System.IO.Path.GetExtension(file.FileName) != ".jpg")
            //    {
            //        throw new Exception("file format error.");
            //    }
            //}
        }


最後是把 filter 掛在 global filter 裡,如下:
Web API Controller 的部份:
在 WebApiConfig.cs 裡加上 config.Filters.Add(new ApiCheckFile()); 見下圖:
 

附帶一提,Register 是在 global.asax 裡被叫用的。微軟的架構常常這樣,硬是拉到另一個目錄的獨立檔案裡,如果 global.asax 沒有這一行,不知所以的人,在 App_Start 裡,建立了一個 WebApiConfig.cs 然後會發現完全沒做用... 見下圖

 
MVC Controller 的部份,
在 FilterConfig.cs 中,加上 filters.Add(new MvcCheckFileFilter()); 如下圖
 

一樣是在 global.asax 中呼叫  FilterConfig.RegisterGlobalFilters,見下圖:

 
不過有趣的地方是,在 global.asax 裡, WebApiConfig.Register 和 FilterConfig.RegisterGlobalFilters 的叫用方法不同,一個是把 function 當作變數,一個是直接呼叫 function, 大家可以比較一下。

附註 1, 關於 OnActionExecutingAsync 和 OnActionExecuting:

Web API 的 filter 中,提供了 OnActionExecutingAsync 和 OnActionExecuting 兩個 method 可以 override ,我實驗的結果是如果兩個 method 都 override 了,只有 OnActionExecutingAsync 會被呼叫。

MVC 的 filter 中,只有 OnActionExecuting 可以 override。
 
More...
Bike, 2022/5/15 上午 10:01:11
使用Lucene.Net達成全文檢索!基礎解說(二)
上一集當中我們完成了Lucene基本操作中的Create與Read,這一集會將CRUD中的Update與Delete的操作方法告訴你,並且本集會著重於講解關於"Norms"與權重(Boost)在Lucene中的運作概念。

首先我們建立一個.Net 6的主控台應用程式
   

建立好後於右側專案右鍵選擇"管理Nuget套件",並選擇"瀏覽">於搜索列中搜尋"Lucene">安裝3.0.3最新穩定版 與 "System.Configuration.ConfigurationManager"

 
 安裝好後就可以於專案內使用Lucene套件囉!
再來依照上一篇的教學建立一套簡單的Lucene查詢

using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers;
using Lucene.Net.Search;
using Lucene.Net.Store;

var _dir = new DirectoryInfo("LuceneDocument");
if (!File.Exists(_dir.FullName))
{
    System.IO.Directory.CreateDirectory(_dir.FullName);
}
var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_CURRENT);
CreateIndex(GetProductsInformation(), _dir, analyzer);

while (true)
{
    Console.Write("請輸入欲查詢字串 :");
    var searchValue = Console.ReadLine();
    Search(searchValue, _dir, analyzer);
}

void CreateIndex(List<Product> information, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using (var directory = FSDirectory.Open(dir))
    {
        using (var indexWriter = new IndexWriter(directory, analyzer, true, IndexWriter.MaxFieldLength.LIMITED))
        {
            foreach (var index in information)
            {
                var document = new Document();
                document.Add(new Field("Id", index.Id.ToString(), Field.Store.YES, Field.Index.NO));
                document.Add(new Field("Name", index.Name, Field.Store.YES, Field.Index.ANALYZED));
                document.Add(new Field("Description", index.Description, Field.Store.YES, Field.Index.ANALYZED));
                indexWriter.AddDocument(document);
            }
            indexWriter.Optimize();
            indexWriter.Commit();
        }
    }
}
void Search(string searchValue, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using (var directory = FSDirectory.Open(_dir))
    {
        var parser = new QueryParser(Lucene.Net.Util.Version.LUCENE_CURRENT, "Description", analyzer).Parse(searchValue);
        using (var indexSearcher = new IndexSearcher(directory))
        {
            var queryLimit = 20;
            var hits = indexSearcher.Search(parser, queryLimit);
            if (!hits.ScoreDocs.Any())
            {
                Console.WriteLine("查無相關結果。");
                return;
            }
            Document doc;
            foreach (var hit in hits.ScoreDocs)
            {
                doc = indexSearcher.Doc(hit.Doc);
                Console.WriteLine("Score :" + hit.Score + ", Id :" + doc.Get("Id") + ", Name :" + doc.Get("Name") + ", Description :" + doc.Get("Description"));
            }
        }
    }
}

List<Product> GetProductsInformation()
{
    return new List<Product> {
            new Product{ Id = 1, Name = "蘋果", Description = "一天一蘋果,醫生遠離我。"},
            new Product{ Id = 2, Name = "橘子", Description = "醫生給娜美最珍貴的寶藏。"},
            new Product{ Id = 3, Name = "梨子", Description = "我是梨子,比蘋果好吃多囉!"},
            new Product{ Id = 4, Name = "葡萄", Description = "吃葡萄不吐葡萄皮,不吃葡萄倒吐葡萄皮"},
            new Product{ Id = 5, Name = "榴槤", Description = "水果界的珍寶!好吃一直吃。"}
        };
}
class Product
{
    public long Id { get; set; }
    public string Name { get; set; } = null!;
    public string Description { get; set; } = null!;
}


好囉! 接下來我們要如何更新索引呢?
更新其實就是將存在的索引刪除並重新建立Document,不存在的則直接新增。
首先準備一組資料準備更新

List<Product> GetUpdateProductsInformation()
{
    return new List<Product>
    {
        new Product{ Id = 6, Name = "香蕉", Description = "運動完後吃根香蕉補充養分。"},
        new Product{ Id = 2, Name = "橘子", Description = "橘子跟柳丁你分得出來嗎?"}
    };
}


*欲更新的Document必須與創建所引時使用的Document欄位相同*

void Update(string key, List<Product> information, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using( var directory = FSDirectory.Open(dir))
    {
        using(var indexWriter = new IndexWriter(directory, analyzer, false, IndexWriter.MaxFieldLength.LIMITED))
        {
            foreach (var index in information)
            {
                var document = new Document();
                document.Add(new Field("Id", index.Id.ToString(), Field.Store.YES, Field.Index.NO));
                document.Add(new Field("Name", index.Name, Field.Store.YES, Field.Index.ANALYZED));
                document.Add(new Field("Description", index.Description, Field.Store.YES, Field.Index.ANALYZED));
                indexWriter.UpdateDocument(new Term("Name", key) ,document);
            }
        }
    }
}


來測試看看
   
可以看見 Name = 橘子 的索引已經改為我們新準備的資料囉。
再來是刪除!
與更新非常相似,只需要使用deleteDocument()就可以了。

void Delete(string key, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using (var directory = FSDirectory.Open(dir))
    {
        using (var indexWriter = new IndexWriter(directory, analyzer, false, IndexWriter.MaxFieldLength.LIMITED))
        {
            indexWriter.DeleteDocuments(new Term("Name", key));
            indexWriter.Optimize();
            indexWriter.Commit();
        }
    }
}


再來看看輸出結果
 可以發現 Score :0.7554128, Id :2, Name :橘子, Description :醫生給娜美最珍貴的寶藏。這筆索引已經被移除囉!
  
可以發現筆者於更新或刪除時都是輸入單一字來做異動,除了表達可以對索引做複合更動外,
是因為更新與刪除索引同樣會使用到分詞器(analyzer),
*所輸入的索引值非ID等數值時必須要配合分詞器的分詞能力*才能取得所想異動的索引喔!


Boost是什麼呢?
Boost 分為 :
1. Index Time Boost : 在建立索引時就計算好的值。例如上一篇中提到的(NORMS)
2. Query Time Boost : 查詢時賦與搜尋條件不同的值以影響結果。
我們先來測試Index Time Boost的部分
void CreateIndexWithBoost(List<Product> information, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using (var directory = FSDirectory.Open(dir))
    {
        using (var indexWriter = new IndexWriter(directory, analyzer, true, IndexWriter.MaxFieldLength.LIMITED))
        {
            foreach (var index in information)
            {
                var document = new Document();
                document.Add(new Field("Id", index.Id.ToString(), Field.Store.YES, Field.Index.NO));
                document.Add(new Field("Name", index.Name, Field.Store.YES, Field.Index.ANALYZED));
                document.Add(new Field("Description", index.Description, Field.Store.YES, Field.Index.ANALYZED));
                document.GetField("Name").Boost = 1.5F;
                document.GetField("Description").Boost = 0.5F;

                indexWriter.AddDocument(document);
            }
            indexWriter.Optimize();
            indexWriter.Commit();
        }
    }
}


並記得重新CreateIndex才能刷新欄位的權重值喔。


很明顯的搜尋出來的Score分數變動了! 但是有沒有發現明明Name欄位的Boost改成了1.5,蘋果的數值卻仍然只有一半呢?
這是因為我們的Search中所參照的欄位為Description,所以在計算Score的時候其實是完全沒有參與的喔!
另外要記得,使用Index Time Boost的時候,欲給予銓重分配的欄位Field.Index不能使用NO_NORMS,不然這個欄位並不會紀錄權重的資料。

再來我們試試看Query Time Boost
void SearchWithBoost(string searchValue, DirectoryInfo dir, StandardAnalyzer analyzer)
{
    using (var directory = FSDirectory.Open(_dir))
    {
        using (var indexSearcher = new IndexSearcher(directory))
        {
            var query = new QueryParser(Lucene.Net.Util.Version.LUCENE_CURRENT, "Name", analyzer).Parse(searchValue);
            var query2 = new QueryParser(Lucene.Net.Util.Version.LUCENE_CURRENT, "Description", analyzer).Parse(searchValue);

            query.Boost = 2.0F;
            query2.Boost = 0.5F;

            BooleanQuery booleanQuery = new BooleanQuery();
            booleanQuery.Add(query, Occur.SHOULD);
            booleanQuery.Add(query2, Occur.SHOULD);

            var hits = indexSearcher.Search(booleanQuery, 20);
            if (!hits.ScoreDocs.Any())
            {
                Console.WriteLine("查無相關結果。");
                return;
            }
            Document doc;
            foreach (var hit in hits.ScoreDocs)
            {
                doc = indexSearcher.Doc(hit.Doc);
                Console.WriteLine("Score :" + hit.Score + ", Id :" + doc.Get("Id") + ", Name :" + doc.Get("Name") + ", Description :" + doc.Get("Description"));
            }
        }
    }
}


這次我們搜尋兩個欄位"Name"與"Description",並使用 BooleanQuery來將其組合。
BooleanQuery中的 Occur有三種參數 : "MUST","MUST_NOT","SHOULD",功能與字面上的意思一樣為"必須要有","必須沒有"與"有無都包含"。

 
查詢出來的分數就不一樣囉!

以上就是這一次的分享,Lucene是一款容易入門但是要實際上戰場卻又十分複雜的功能,想要達成真正高效能的全文檢索,在前期的文件規畫配置與資料的權重配比都是一個巨大的挑戰。未來會繼續分享關於Lucene的其他有趣功能,還請繼續期待呦!
另外也可以到GitHub下載我的範例來參考呦!
GitHub: https://github.com/g13579112000/Lucene

參考文件:
1. 黑暗大大的全文檢索筆記 : https://blog.darkthread.net/blog/lucene-net-notes-1/
2. Makble : http://makble.com/lucene-field-boost-example
3. CSDN Jack2013tong 文章 : https://blog.csdn.net/huwei2003/article/details/53408388
More...
梨子, 2022/4/20 下午 09:34:03
使用 Gmail API 及 Google API 憑證的取得流程
這裡是我測試 Gmail API 和 Google API 憑証的一些記錄。

如果你的目的是要使用 Gmail Api 取代舊的 Gmail SMTP 來發送通知信,建議你先跳到最下方看一下結論。

如果你是想要看一下 Gmail API 和 Google API 憑証的使用方法,可以看一下這篇文章。

1. 在 google cloud platform 建立新的專案.
https://console.cloud.google.com/

 
 



啟用 Gmail API







 



 


 
 



因為我們要透過 OAuth  取得使用者授權,所以要設定使用 OAuth 的同意畫面。

 
指定授權的範圍
 

 
因為剛建立的專案,不會被公開,所以要指定測試使用者
 
如果要給任意使用者,必需經過發布的流程,但準備工作有點麻煩,所以這次就不發布了。
 




建立 OAuth 2.0 用戶端 ID 憑証
 
這裡除了名稱外,還有一個設定重導 Uri 的項目。現在不填寫,但稍後要回來補這個資料。
 
 
 下載 json 之後,命名為 client_secret.json 保留後續使用。


再來就要建立專案了. 用 VS2022 建立一個新專案
  


 




記錄網址, 本測試專案是 https://localhost:44340/ ,請依實際網址為準。



回到 OAuth 2.0 用戶端 ID 的設定頁. 在已授權的重新導向 URI 中填入 https://localhost:44340/Home/AuthReturn (填入的網址依實際專案的狀況,可能會有變化)
 
 

在 VS2022 中,使用 Nuget 安裝套件: (有漏的再麻煩和我說)
Google.Apis.Gmail.v1
Google.Apis.Auth
MimeKit (發送 gmail 時使用)

建立認証用的網址:
建立一個 Action, 用來取得認証用的網址:
        /// <summary>
        /// 取得授權的項目
        /// </summary>
        static string[] Scopes = { GmailService.Scope.GmailSend };

        // 和登入 google 的帳號無關
        // 任意值,若未來有使用者認証,可使用使用者編號或登入帳號。
        string Username = "ABC";

        /// <summary>
        /// 存放 client_secret 和 credential 的地方
        /// </summary>
        string SecretPath = @"D:\project\GmailTest\Data\Secrets";

        /// <summary>
        /// 認証完成後回傳的網址, 必需和 OAuth 2.0 Client Id 中填寫的 "已授權的重新導向 URI" 相同。
        /// </summary>
        string RedirectUri = $"https://localhost:44340/Home/AuthReturn";

        /// <summary>
        /// 取得認証用的網址
        /// </summary>
        /// <returns></returns>
        public async Task<string> GetAuthUrl()
        {
            using (var stream = new FileStream(Path.Combine(SecretPath, "client_secret.json"), FileMode.Open, FileAccess.Read))
            {
                FileDataStore dataStore = null;
                var credentialRoot = Path.Combine(SecretPath, "Credentials");
                if (!Directory.Exists(credentialRoot))
                {
                    Directory.CreateDirectory(credentialRoot);
                }

                //存放 credential 的地方,每個 username 會建立一個目錄。
                string filePath = Path.Combine(credentialRoot, Username);
                dataStore = new FileDataStore(filePath);

                IAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
                {
                    ClientSecrets = GoogleClientSecrets.Load(stream).Secrets,
                    Scopes = Scopes,
                    DataStore = dataStore
                });

                var authResult = await new AuthorizationCodeWebApp(flow, RedirectUri, Username)
                .AuthorizeAsync(Username, CancellationToken.None);

                return authResult.RedirectUri;
            }
        }


執行結果:
 
用 chrome 開啟產生的網址: 


選取任一帳號,如果出現以下錯誤,請回到 "OAuth 同意畫面" 去新增測試使用者


 
因為應用程式尚未發布,所以會看到警告,勇敢的繼續下去


這裡會要求授權使用你的名義發送信件。(這是在程式中取得授權的項目 Scopes 中所指定的)


再繼續之後,會被重導至我們在 redirectUri  指定的網址。因為我們尚未完成,所以會看到錯誤,順便也可以看一下,會帶回哪一些參數。有 state, code, scope,共三個。


順便看一下,google 的套件會在 Credentials 的目錄下幫使用者建立一個目錄,在完成驗証前,會先放一個 System.String-oauth_XXX 的檔案,裡面的值和回傳的 state 是一樣的,這個應該是用來驗証回傳資料的。



接下來我們要新增 Action "AuthReturn" 如下:

        public async Task<string> AuthReturn(AuthorizationCodeResponseUrl authorizationCode)
        {
            string[] scopes = new[] { GmailService.Scope.GmailSend };

            using (var stream = new FileStream(Path.Combine(SecretPath, "client_secret.json"), FileMode.Open, FileAccess.Read))
            {
                //確認 credential 的目錄已建立.
                var credentialRoot = Path.Combine(SecretPath, "Credentials");
                if (!Directory.Exists(credentialRoot))
                {
                    Directory.CreateDirectory(credentialRoot);
                }

                //暫存憑証用目錄
                string tempPath = Path.Combine(credentialRoot, authorizationCode.State);            

                IAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow(
                new GoogleAuthorizationCodeFlow.Initializer
                {
                    ClientSecrets = GoogleClientSecrets.Load(stream).Secrets,
                    Scopes = scopes,
                    DataStore = new FileDataStore(tempPath)
                });

                //這個動作應該是要把 code 換成 token
                await flow.ExchangeCodeForTokenAsync(Username, authorizationCode.Code, RedirectUri, CancellationToken.None).ConfigureAwait(false);

                if (!string.IsNullOrWhiteSpace(authorizationCode.State))
                {                
                    string newPath = Path.Combine(credentialRoot, Username);
                    if (tempPath.ToLower() != newPath.ToLower())
                    {
                        if (Directory.Exists(newPath))
                            Directory.Delete(newPath, true);

                        Directory.Move(tempPath, newPath);
                    }
                }

                return "OK";
            }
        }


再跑一次上面的流程,最後回到 AuthReturn

在 D:\project\GmailTest\Data\Secrets\Credentials\ABC 裡面會產生一個檔案: 這個就是我們的 token 了。


看一下裡面的內容, 有 access_token, refresh_token, scope 等等, 用途應該很好猜了.. 不知道各項目的目途也沒有關係。只要有這個 token 就可以了。


refresh_token 的效期請參考以下文件:
https://developers.google.com/identity/protocols/oauth2 。也可以參考下圖, 若是要用 gmail api 來發送通知信(例如連絡我們),紅色的地方是比較令人困擾的,例如 6 個月以上,沒有人留言,原來留下的 refresh_token 就失效了。使用者必需重新建立一個 refresh_token 。

 
最後來使用 gmail api 發送通知信, 直接看程式碼如下: 在這個過程中遇到最大的問題除了憑証問題之外,另一個問題是編碼。直到最後找到可以用 MimeKit 把 System.Net.Mail.MailMessage 編碼成 Gmail API 的格式才解決。程式碼如下:

        public async Task<bool> SendTestMail()
        {
            var service = await GetGmailService();

            GmailMessage message = new GmailMessage();
            message.Subject = "標題";
            message.Body = $"<h1>內容</h1>";
            message.FromAddress = "bikehsu@gmail.com";
            message.IsHtml = true;
            message.ToRecipients = "bikehsu@gmail.com";
            message.Attachments = new List<Attachment>();

            string filePath = @"C:\Users\bike\Pictures\Vegetable_pumpkin.jpg";    //要附加的檔案
            Attachment attachment1 = new Attachment(filePath);
            message.Attachments.Add(attachment1);

            SendEmail(message, service);
            Console.WriteLine("OK");

            return true;
        }

        async Task<GmailService> GetGmailService()
        {
            UserCredential credential = null;

            var credentialRoot = Path.Combine(SecretPath, "Credentials");
            if (!Directory.Exists(credentialRoot))
            {
                Directory.CreateDirectory(credentialRoot);
            }

            string filePath = Path.Combine(credentialRoot, Username);

            using (var stream = new FileStream(Path.Combine(SecretPath, "client_secret.json"), FileMode.Open, FileAccess.Read))
            {
                credential = await GoogleWebAuthorizationBroker.AuthorizeAsync(
                GoogleClientSecrets.Load(stream).Secrets,
                Scopes,
                Username,
                CancellationToken.None,
                new FileDataStore(filePath));
            }

            var service = new GmailService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = credential,
                ApplicationName = "Send Mail",
            });

            return service;
        }


        public class GmailMessage
        {
            public string FromAddress { get; set; }
            public string ToRecipients { get; set; }

            public string Subject { get; set; }
            public string Body { get; set; }
            public bool IsHtml { get; set; }

            public List<System.Net.Mail.Attachment> Attachments { get; set; }
        }


        public static void SendEmail(GmailMessage email, GmailService service)
        {
            var mailMessage = new System.Net.Mail.MailMessage();
            mailMessage.From = new System.Net.Mail.MailAddress(email.FromAddress);
            mailMessage.To.Add(email.ToRecipients);
            mailMessage.ReplyToList.Add(email.FromAddress);
            mailMessage.Subject = email.Subject;
            mailMessage.Body = email.Body;
            mailMessage.IsBodyHtml = email.IsHtml;

            if (email.Attachments != null)
            {
                foreach (System.Net.Mail.Attachment attachment in email.Attachments)
                {
                    mailMessage.Attachments.Add(attachment);
                }
            }

            var mimeMessage = MimeKit.MimeMessage.CreateFromMailMessage(mailMessage);

            var gmailMessage = new Google.Apis.Gmail.v1.Data.Message
            {
                Raw = Encode(mimeMessage)
            };

            Google.Apis.Gmail.v1.UsersResource.MessagesResource.SendRequest request = service.Users.Messages.Send(gmailMessage, "me");

            request.Execute();
        }

        public static string Encode(MimeMessage mimeMessage)
        {
            using (MemoryStream ms = new MemoryStream())
            {
                mimeMessage.WriteTo(ms);
                return Convert.ToBase64String(ms.GetBuffer())
                    .TrimEnd('=')
                    .Replace('+', '-')
                    .Replace('/', '_');
            }
        }



收到的信件:



結論:
使用 Gmail API 最大的原因是要增加安全性,和舊的 smtp 不同的地方是,使用 gmail api 之後,客戶不需要提供 gmail 的帳號和密碼就可以讓系統使用 gmail 發送信件,不過由於 refresh_token 的效期問題,可能會造成無法發送通知信而沒有任何人發現的情況,整個實用性會變的很低。

另一個還沒有測試的部份是應用程的啟用。這個審核不知道會不很麻煩,不過可以而知的時,整個流程會花更多的時間。

取代的做法: 可能要改用 Amazon 的 SES 來寄信,而且為了避免每個小網站都要跑 SES 的建立流程,準備來寫一個 API 給各網站使用,可以發送簡單的通知信。

以上的程式碼可以在這裡下載: https://github.com/bikehsu/GmailTest
More...
Bike, 2022/4/10 下午 09:31:15
MySQL效能檢測與優化 (Workbench)
首先尋找出一支欲調效的Table或Query
 
再來我們可以先使用左上方工具列 'Query' 內的 Explain Current Statement
 便可以得到如以下的連結表
 
 
由圖中可以清楚的看見每一段Query後的資料量,並發現有數張表單是呈現紅色 'Full Table Scan'
,這代表該段Query對這張表單的每一行欄位都做了掃描。
再來我們在我們的Query前方加上 'EXPLAIN' 並執行
 
便得到了該段Query所關聯的表單與其詳細資訊

其中針對EXPLAIN的欄位說明如下:
table:關連到的資料表(Table)會顯示在此。
type:顯示使用了何種類型。從最優至最差的類型為const、eq_reg、ref、range、indexhe、ALL。
possible_keys:顯示可能使用到的索引。此為從WHERE語法中選擇一個適合的欄位名稱。
key:實際使用到的索引。如果為NULL,則是沒有使用索引。
key_len:使用索引的長度。長度越短 準確性越高。
ref:顯示那一列的索引被使用。一般是一個常數(const)。
rows:MySQL用來返回資料的筆數。
Extra:MySQL用來解析額外的查詢訊息。如果此欄位的值為:Using temporary和Using filesort,表示MySQL無法使用索引。
Extra為MySQL用來解析額外的查詢訊息,其中欄位值所代表的意義如下:
Distinct:當MySQL找到相關連的資料時,就不再搜尋。
Not exists:MySQL優化 LEFT JOIN,一旦找到符合的LEFT JOIN資料後,就不再搜尋。
Range checked for each Record(index map:#):無法找到理想的索引。此為最慢的使用索引。
Using filesort:當出現這個值時,表示此SELECT語法需要優化。因為MySQL必須進行額外的步驟來進行查詢。
Using index:返回的資料是從索引中資料,而不是從實際的資料中返回,當返回的資料都出現在索引中的資料時就會發生此情況。
Using temporary:同Using filesort,表示此SELECT語法需要進行優化。此為MySQL必須建立一個暫時的資料表(Table)來儲存結果,此情況會發生在針對不同的資料進行ORDER BY,而不是GROUP BY。
Using where:使用WHERE語法中的欄位來返回結果。
System:system資料表,此為const連接類型的特殊情況。
Const:資料表中的一個記錄的最大值能夠符合這個查詢。因為只有一行,這個值就是常數,因為MySQL會先讀這個值然後把它當做常數。
eq_ref:MySQL在連接查詢時,會從最前面的資料表,對每一個記錄的聯合,從資料表中讀取一個記錄,在查詢時會使用索引為主鍵或唯一鍵的全部。
ref:只有在查詢使用了非唯一鍵或主鍵時才會發生。
range:使用索引返回一個範圍的結果。例如:使用大於>或小於<查詢時發生。
index:此為針對索引中的資料進行查詢。
ALL:針對每一筆記錄進行完全掃描,此為最壞的情況,應該盡量避免。

 
 
 
我們可以注意到 `browse_history` 這個表單在Query中並沒有使用索引,
可以從上方的QueryString中發現該段Query的Left Join是查詢`product_id`這個欄位,前往這個Table並幫其建立Index來增加檢索效率。
  
步驟三的時候可以選擇複數欄位來建立Index,但是要注意的是在使用索引欄位作為條件時,如果該索引是複合索引,那麼必須使用到該索引中的第一個欄位作為條件時才能保證系統使用該索引,否則該索引將不會被使用,並且應儘可能的讓欄位順序與索引順序相一致。

再來是在Query語句中應該注意以下事項
1.避免在索引列上進行運算, 這將導致引擎放棄使用索引而進行全表掃描。
2.不使用NOT IN和<>操作, NOT IN和<>操作都不會使用索引將進行全表掃描。NOT IN可以NOT EXISTS代替,id<>9則可使用id>9 or id<9來代替。
3.檢查where條件與order by 欄位,避免全表掃描。
4.應儘量避免在 where 子句中對欄位進行 null 值判斷,否則將導致引擎放棄使用索引而進行全表掃描,如: 可以在num上設定預設值0,確保表中num列沒有null值,然後這樣查詢: select id from t where num = 0
5.應儘量避免在 where 子句中使用 or 來連線條件,如果一個欄位有索引,一個欄位沒有索引,將導致引擎放棄使用索引而進行全表掃描。可以拆分條件,進行子句的union all查詢,如: select id from t where num=10 or name = 'admin'  拆分 select id from t where num = 10 union all select id from t where name = 'admin'
6.in 和 not in 也要慎用,否則會導致全表掃描,如: select id from t where num in(1,2,3) 對於連續的數值,能用 between 就不要用 in 了: select id from t where num between 1 and 3,
用 exists 代替 in 是一個好的選擇: select num from a where num in(select num from b) 換成 select num from a where exists(select 1 from b where num=a.num)
7.like語句的%不要前置, 否則索引失效 將導致全表掃描。
8.如果在 where 子句中使用引數,也會導致全表掃描。 因為SQL只有在執行時才會解析區域性變數,但優化程式不能將訪問計劃的選擇推遲到執行時;它必須在編譯時進行選擇。然 而,如果在編譯時建立訪問計劃,變數的值還是未知的,因而無法作為索引選擇的輸入項。
9.應儘量避免在where子句中對欄位進行函式操作,這將導致引擎放棄使用索引而進行全表掃描。
10.不要在 where 子句中的“=”左邊進行函式、算術運算或其他表示式運算,否則系統將可能無法正確使用索引。
11.在使用索引欄位作為條件時,如果該索引是複合索引,那麼必須使用到該索引中的第一個欄位作為條件時才能保證系統使用該索引,否則該索引將不會被使用,並且應儘可能的讓欄位順序與索引順序相一致。
12.Update 語句,如果只更改1、2個欄位,不要Update全部欄位,否則頻繁呼叫會引起明顯的效能消耗,同時帶來大量日誌。
對於多張大資料量(這裡幾百條就算大了)的表JOIN,要先分頁再JOIN,否則邏輯讀會很高,效能很差。
13.select count(*) from table;這樣不帶任何條件的count會引起全表掃描,並且沒有任何業務意義,是一定要杜絕的。
14.任何地方都不要使用 select * from t ,用具體的欄位列表代替“*”,不要返回用不到的任何欄位。
15.避免頻繁建立和刪除臨時表,以減少系統表資源的消耗。臨時表並不是不可使用,適當地使用它們可以使某些例程更有效,例如,當需要重複引用大型表或常用表中的某個資料集時。但是,對於一次性事件, 最好使用匯出表。
16.在新建臨時表時,如果一次性插入資料量很大,那麼可以使用 select into 代替 create table,避免造成大量 log ,以提高速度;如果資料量不大,為了緩和系統表的資源,應先create table,然後insert。
17.儘量拆分大的 DELETE 或INSERT 語句,批量提交SQL語句。
18.儘量避免使用遊標,因為遊標的效率較差,如果遊標操作的資料超過1萬行,那麼就應該考慮改寫。

參考來源:
1. http://blog.twbryce.com/mysql-explain/
2. https://www.gushiciku.cn/pl/gkis/zh-tw
3. https://www.itread01.com/content/1548581229.html
More...
梨子, 2022/3/28 下午 09:00:29
|< 12345 >|
頁數 1 / 5 下一頁
~ Uwinfo ~