Quantcast
Channel: 黑暗執行緒
Viewing all 2311 articles
Browse latest View live

使用 C# 整合 OpenCC 執行中文繁簡轉換

$
0
0

前篇文章介紹了輕巧但威力強大的 OpenCC,使用 opencc.exe 可輕鬆完成繁簡轉換。

如果我們要在 .NET 裡寫一個函式招喚 OpenCC 將繁體字串轉成簡體字串該怎麼做?

呼叫外部 .exe 這等小事,自然難不倒 .NET 老鳥,生個 System.Diagnostics.Process,給對 exe 路徑,弄兩個隨機暫存檔放待翻文字與輸出結果,等待 opencc.exe 執行完畢,讀出結果刪掉暫存檔,搞定收工!

    public static class OpenCCConverter
    {

        static string GetPath(string file) => $"X:\Tools\OpenCC\{file}";
        static string GetTempFile() => $"X:\Temp\OpenCCFiles\{Guid.NewGuid()}";

        static void CallOpenCC(string inputFile, string outputFile, string configFile)
        {
            var si = new ProcessStartInfo()
            {
                FileName = GetPath("opencc.exe"),
                Arguments = $"-i {inputFile} -o {outputFile} -c {GetPath(configFile)}",
                CreateNoWindow = true
            };
            var p = new Process()
            {
                StartInfo = si
            };
            p.Start();
            p.WaitForExit();
        }

        /// <summary>
        /// 將繁體轉為簡體
        /// </summary>
        /// <param name="text"></param>
        /// <returns></returns>
        public static string ToChsString(string text)
        {
            var inFile = GetTempFile();
            File.WriteAllText(inFile, text);
            var outFile = GetTempFile();
            CallOpenCC(inFile, outFile, "tw2s.json");
            var result = File.ReadAllText(outFile);
            File.Delete(inFile);
            File.Delete(outFile);
            return result;
        }
    }

這個寫法醜歸醜但很管用,還十分簡單明瞭。只是啟動外部程序成本較高,加上要不斷建檔刪檔,就算只是翻譯一個字元也要動用兩個暫存檔,執行效能及資源使用效率並不好。

無意發現 OpenCC 將核心邏輯放在獨立程式庫 – opencc.dll,何不透過 Interoperability由 C# 呼叫 C++ 函式直接執行轉換?於是,不知天高地厚的 C++ 麻瓜開啟了 Unmanged DLL 整合大冒險!

先用 Console Application 測試,為求部署方便,我將 OpenCC 納入專案,並設定編譯時輸出到 \bin\opencc 目錄:

開發心得如下:

  1. C# 要呼叫 C++ 寫的 DLL,起手式是用 DllImport 宣告外部函式對應到 C++ 函式,會遇到的挑戰主要是參數的型別傳換。
  2. 在 Github 討論串找到網友 C# DllImport 的程式片段,由於最後有成功,極富參考價值。我學到可先用 opencc_open() 指定轉換設定 json 檔建立 Instance,再呼叫 opencc_convert_utf8() 傳入 Instance Pointer 及待轉換字串,取得結果字串 IntPtr,再轉為 C# 字串。
  3. DllImport 設定不正確時,opencc_open() 時即會出錯,會傳回之類的訊息
    Unable to load DLL 'opencc.dll': The specified module could not be found. (Exception from HRESULT: 0x8007007E)
    我遇過兩種情況:1) DllImport 指定的 opencc.dll 路徑有誤 2) 執行主機缺少 Visual C++ Runtime。
  4. 官方下載的 OpenCC 1.0.1 Windows 版使用 Visual Studio 2012 編譯,需要「Visual Studio 2012 最新支援的 Visual C++ 可轉散發套件」,微軟支援網站有個 最新支援的 Visual C++ 下載網頁已整理好所有 VC++ 版本的可轉散發套件,請自行依所需版本下載安裝。
    若懷疑跟 C++ Runtime 套件沒裝有關,最簡單的驗證方法是手動執行 opencc.exe,若彈出缺少 msvcp***.dll 之類的錯誤訊息就是了。
  5. opencc_convert_utf8() 轉換失敗時不會出錯,會傳回 IntPtr.Zero,詳細錯誤訊息需另外呼叫 opencc_error() 取得。
  6. 我一度卡在一個關鍵點,待轉換字串與結果字串,形式為記憶體指標指向一段 UTF8 編碼格式的 byte[],與 string 之間需要特殊函式轉換,我在 Stackoverflow 找到可用範例

瞎弄一陣,萬萬沒想到還真被 C++ 麻瓜試出來了,可執行程式範例如下:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            Debug.WriteLine(
                OpenCCHelper.ConvertToChs(
                    "預設記憶體大小與硬碟容量"));
            Console.ReadLine();
        }
    }

    public static class OpenCCHelper
    {
        [DllImport("opencc\\opencc.dll", EntryPoint = "opencc_open")]
        static extern IntPtr opencc_open(string configFileName);

        [DllImport("opencc\\opencc.dll", EntryPoint = "opencc_convert_utf8")]
        static extern IntPtr opencc_convert_utf8(Int64 opencc, IntPtr input, long length);

        static IntPtr OpenCCInstance = IntPtr.Zero;

        static OpenCCHelper()
        {
            OpenCCInstance = opencc_open(".\\opencc\\tw2sp.json");
        }

        //https://stackoverflow.com/a/10773988/288936
        public static IntPtr NativeUtf8FromString(string managedString)
        {
            int len = Encoding.UTF8.GetByteCount(managedString);
            byte[] buffer = new byte[len + 1];
            Encoding.UTF8.GetBytes(managedString, 0, managedString.Length, buffer, 0);
            IntPtr nativeUtf8 = Marshal.AllocHGlobal(buffer.Length);
            Marshal.Copy(buffer, 0, nativeUtf8, buffer.Length);
            return nativeUtf8;
        }

        public static string StringFromNativeUtf8(IntPtr nativeUtf8)
        {
            int len = 0;
            while (Marshal.ReadByte(nativeUtf8, len) != 0) ++len;
            byte[] buffer = new byte[len];
            Marshal.Copy(nativeUtf8, buffer, 0, buffer.Length);
            return Encoding.UTF8.GetString(buffer);
        }

        public static string ConvertToChs(string text)
        {
            IntPtr inStr = NativeUtf8FromString(text);
            IntPtr outStr = opencc_convert_utf8(OpenCCInstance.ToInt64(), inStr, -1);
            Marshal.FreeHGlobal(inStr);
            return StringFromNativeUtf8(outStr);

        }
    }

}

如下圖,我成功呼叫 opencc.dll 完成繁簡轉換。

核子試爆成功是第一步,要寫成共用元件還會再遇到一些問題,例如:x86/x64 必須使用不同 opencc.dll、部署到 ASP.NET 網站時 DllImport 路徑需動態指向網站資料夾、Thread-Safe 考量、Memory Leak 疑慮... C++ 麻瓜大冒險尚未結束,下集待續。

(聲明:程式為門外漢參考爬文及測試所得,如有 C/C++ 高人路過,請鞭小力一點並不吝指正)


2018 台北星光馬

$
0
0

在 2015 年跑過台北星光夜跑,三年後,雖然主辦單位不同,但賽道與時間幾乎一樣,來回味久違的夜跑滋味。

全馬下午四點半起跑,提早出門搭捷運慢慢晃過去,搭接駁車前買了 600 cc 保特瓶喝下肚建立安全庫存,避免天熱缺水狂飲來不及吸收搞到肚子難受。兩點四十左右搭上接駁車有兩段小插曲,車上悶了近十分鐘,有跑友提醒司機才想起忘了開冷氣(登楞),然後疑似行車路線有誤繞了一圈,開了快 20 分鐘才到大佳國小(登楞)。即便如此,三點出頭就到了會場,先在公園大帳篷下躲太陽,快四點才去寄物,順便四處晃晃,噴泉附近水霧瀰漫挺消暑的。

會場有個不斷冒出白煙的醫務帳篷,原來是運動噴劑區,好點題呀,有創意。

四點半起跑,此時天氣轉陰偶有徐風,但氣溫仍破 30 度,不是該拼成績的場子,開心就好。

大會規劃得挺好,每公里都有里程標示,一路上里程標示清晰,交管周到,補給無虞(哆啦A夢小蛋糕、薯片跟西瓜好好吃哦)。而我的 fenix 3 送修回來了,工程師研判為軟體問題,只升級了作業系統連系統都沒重置(不過順便換新錶帶是意外驚喜,Garmin 真是佛心來著),資料都在。而經過實戰測試,30K 之後 GPS 大飄移的問題已消失,算是修好了。

端午快到了,河道上有人在練習划龍舟,吼嘿吼,吼嘿吼,嘿~ 吼!嘿!吼~~~

這場與忠孝哥同跑,剛過 10K,忠孝哥忽然心律莫名飆高畏寒,25K 後還頻頻大腿瀕臨抽筋,一整個失常。 由於是在水站吃完西瓜不久發生,我研判應是吃到倒吊子西瓜所致(大誤),另一種可能是被賽道上的異次元力量干擾,建請未來大會勱察賽道時應請道士隨行(再大誤)。總之,今天不排目標計劃,隨遇而安囉~

跑完第一圈天色開始變暗,對岸 11K 健跑組的綠色 LED 手環的點點星光串成一長串,很是好看。

行經百齡橋時遇到大批消防車從頭上急駛而過,不久後河濱路燈全滅,回家才知剛好遇上士林夜市火警斷電

夜景很美,沒帶腳架拍不下來。好不容易在河岸找到一塊位置適中的水泥護欄,試了幾次,終於拍出一張像樣的。

跑跑走走,但有打起精神趕了一段路,趕在 5:20 之內完賽,成績普普,沒想到還有超過一半以上全馬跑友還沒回來。

完賽時會場人潮已散,速速領完東西坐在會場空地拉筋吹著涼風,有種度假的悠閒感。(但聽說半馬組領完賽禮排隊要排近半小時,跑友大抓狂。)

完賽禮比想像豐富,獎牌蠻好看的,還意外有一枚金幣。

獎牌。

 

金幣。

 

跑得蠻開心的,將本場列入回袋賽事。

【茶包射手日記】SQLAgent 無法執行批次檔

$
0
0

燃燒一小時寶貴青春才查出問題 Orz,PO 文留念。

同事報案,某個用 SQL Agent 定期跑的批次檔 (.bat) 執行無效,原因不明。其寫法類似如下範例,看起來沒什麼問題:

實測開 DOS 視窗直接跑 ImportBOMFromSysA.bat 正常,於是我將偵察方向導向 SQL Agent 執行時工作目錄是 Windows\System32 所致,但檢查該批次檔有依 TIPS-指定主控台應用程式的工作目錄一文所提使用 CD 改路徑技巧,加上其 Log 檔未輸出至 System32,初步排除可能。嘗試手動執行 SQL Agent Job,執行歷程顯示程式執行正常,Exit Code 為 0,代表程式未出錯,而 Log 檔未見相關偵錯訊息軌跡,感覺是程式完全沒執行。

開啟 Process Monitor 觀察,至始至終沒看到讀寫 ImportBOMFromSysA.bat 檔案的記錄,這就不合理了。試將 .bat 檔搬走,SQL Job 居然也沒出錯,一怒之下,將原指令清除改成 D:\Batch\WTF.bat,這才冒出找不到執行檔的錯誤。

回頭再看一次原指令,我似乎懂了什麼。恢復原寫法,將 rem 註解行刪除,ImportBOMFromSysA.bat 便跑了起來。

原來,命令輸入欄雖然可以輸入多行,只有第一行是有效的啊啊啊啊...

理解了這點,下對關鍵字蒐集不少人的踩坑經驗:

面對此一限制的有效解法是用 &&  串接多行指令,但我們的案例第一列是 REM 註解不適用本招,因此我將 REM 移至第二行解決問題。

VS2017 無法載入 MVC4 專案

$
0
0

最近接連遇到兩次的問題。

首先是某個用 Visual Studio 2017 開發的專案,同事 T 從 TFS 取回最新版以 VS2017 開啟,其中卻有兩個 MVC 專案呈現截入失敗,其他還有 Class Library 及 Web Site Project 等多個專案則沒問題;改用 VS2015 開啟則能正常載入。

嘗試重新載入專案會出現以下錯誤訊息,並附上一段說明連結:

SomeMVC\SomeMVC.csproj: 找不到這種專案類型的基礎應用程式。請嘗試這個連結以取得其他資訊: (其英文為 The application which this project type is based on was not found. )
http://go.microsoft.com/fwlink/?LinkID=299083&projecttype=E3E379DF-F4C6-4180-9B81-6769533ABE47

但說明連結已失效最終被導向 http://www.asp.net,斷了線索。

同事 T 有使用 VS2017 在開發其他 MVC 專案,當場實測開啟其他 MVC 專案正常,排除 VS2017 未安裝 MVC 專案型別支援的可能。

無獨有偶,同事 J 也回報他重新安裝 VS2017 後,也出現前幾天還在維護的 MVC 專案卻載入失敗況,請我試試有無問題。一樣是我的 VS2017 可開啟,同事的 VS2017 無法載入。

歸納兩起案例:

  1. 訊息指向 VS2017 不支援 MVC 專案型別,但當下開啟其他 MVC 專案又正常。
  2. 我的 VS2017 開啟問題 MVC 專案並無問題。
  3. 同事改用 VS2015 開啟問題 MVC 專案則正常。
  4. 無法載入的 MVC 專案共通點是多年前建立 (甚至可回溯至 VS2012/VS2013 時代),猛然發現,問題專案都是 ASP.NET MVC4!
  5. 同事 J 的案例更有趣,重新安裝 VS2017 後,前幾天還在維護的 MVC 專案忽然無法載入。

爬文在 VS 討論版找到解法

使用文字編譯器開啟問題 .csproj,其中有個 ProjectTypeGuids,長得類似 <ProjectTypeGuids>{E3E379DF-F4C6-4180-9B81-6769533ABE47};{349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc}</ProjectTypeGuids>

將第一個 {E3E379DF-F4C6-4180-9B81-6769533ABE47}; 刪掉,經實測可立即排除問題。

不過,網路查到的狀況發生於 VS2017RC 版,依據 VS Team 在 2017/3/2 的留言,問題已於正式 Release 時修正。我的 Visual Studio 2017 是 RTM 時安裝一路升級,開啟 MVC4 專案沒問題。同事 T 安裝時間較晚,同事 J 則是移除重裝最新版時出狀況。莫非是新版 VS2017 移除了過時的 MVC4 專案支援?

研究問題的過程中,學到兩則冷知識:

  1. ASP.NET MVC 專案是所謂的 Flavored Project (或稱為 Project Subtype,專案子型別)
    Eilon Lipton's Blog - Opening an ASP.NET MVC project without having ASP.NET MVC installed- The p
    意思是 ASP.NET MVC 並未另建新專案型別,而是在 ASP.NET Web Application Project 專案型別上加料讓專案具有 MVC 特性。
  2. .csproj 中有個 ProjectTypeGuids 可包含多個 GUID 值代表專案子型別
    List of Visual Studio Project Type GUIDs - CodeProject整理了專案子型別 GUID 清單,查表得知問題 MVC4 專案共有
    * ASP.NET MVC 4 {E3E379DF-F4C6-4180-9B81-6769533ABE47}
    * ASP.NET MVC 5 {349C5851-65DF-11DA-9384-00065B846F21}
    * C# {FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
    三種專案子型別。

結論,新安裝 VS2017 開啟 MVC4 專案如遇無法載入狀況,手動編譯 .csproj,移除  ProjectTypeGuids 中的 {E3E379DF-F4C6-4180-9B81-6769533ABE47} 可排除問題。

但成功載入後,可能還有缺少參照組件問題,例如 System.Net.Http.Formatting 來自 GAC,若機器沒裝過 ASP.NET MVC4 套件,就不會有 C:\Program Files (x86)\Microsoft ASP.NET\ASP.NET MVC 4 資料夾跟相關組件。

要解決此問題,可移除缺少的參照組件改由 NuGet 下載安裝,或是一不做二不休將專案升級成 MVC5,升級方式可參考官方文件:如何升級 ASP.NET MVC 4 和 Web API 專案,以 ASP.NET MVC 5 和 Web API 2 - Microsoft Docs

CSHTML Layout Page、Partial View 執行順序實驗

$
0
0

維護 ASP.NET MVC 專案遇上巢狀 Layout 引用 Partial View 的情境,無法斷定執行先後順序,想必是自己觀念不清,做了以下實驗驗證,順手分享之。

假設有 ASP.NET MVC 巢狀 Layout 並混用 Partial View 結構如下:

_Layout.cshtml

@{
    System.Diagnostics.Debug.WriteLine("_Layout.cshtml");
}<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>@ViewBag.Title</title></head><body>

@Html.Partial("_PartView1")

@RenderBody()

</body></html>

_PartView1.cshtml (_PartView2.cshtml 及 _PartView3.cshtml 做法相同,只有數字不同)

@{
    System.Diagnostics.Debug.WriteLine("PartialView1");
}<div>Partial View 1</div>

_NestedLayout.cshtml

@{
    Layout = "~/Views/Shared/_Layout.cshtml";
    System.Diagnostics.Debug.WriteLine("_NestedLayout.cshtml");
}
@Html.Partial("_PartView2")
@RenderBody()

Index.cshtml

@{
    Layout = "~/Views/Shared/_NestedLayout.cshtml";
    System.Diagnostics.Debug.WriteLine("Index.cshtml");
}

@Html.Partial("_PartView3")<h2>Index</h2>

HomeController.cs

    public class HomeController : Controller
    {
        // GET: Home
        public ActionResult Index()
        {
            System.Diagnostics.Debug.WriteLine("Index Action");
            return View();
        }
    }

執行結果不難預期:

問題來了,HomeController.cs、Index.cshtml、_PartView1.cshtml、_PartView2.cshtml、_PartView3.cshtml、_NestedLayout.cshtml、_Layout.cshtml 都埋了 System.Diagnostics.Debug.WriteLine(),將以什麼順序執行?

給大家 20 秒自我測驗。

答案揭曉:

HomeController Index Action –> Index.cshtml –> Partial View 3 –> _NestedLayout –> Partial View 2 -> _Layout –> Partial View 1

這順序不難理解,基本上就是從 HomeController.cs 開始,從 Index.cshtml、_NestedLayout.cshtml 到 _Layout.cshtml,由內而外的順序將 Razor View 轉為 HTML,生成 HTML 過程才載入 Partial View。

(原想找到官方文件證實,搜索未獲,十方大德如有知悉懇請不吝補充)

在 Partial View 與 View 間使用 ViewBag 傳送資料

$
0
0

在 ASP.NET MVC View 引用伺服器端傳來的資料,正統做法是定義 View Model 類別,Action return View(viewModelObject),在 CSHTML 宣告 @model 定義強型別並使用 Razor 語法存取 Model 變數。(延伸閱讀:mrkt 的程式學習筆記: ASP.NET MVC 的ViewModel - 基礎篇 )

但如果是要傳遞簡單的數字或字串(像是啟用特定功能的旗標、頁面標題... 等等),為此定義 View Model 類別有點小題大作,此時 ViewData 跟 ViewBag 是不錯的輕巧選擇。(不建議使用 TempData、Session,延伸閱讀:[探索 10 分鐘] 寫點有關 ASP.NET MVC ViewModel, ViewData, ViewBag, TempData 的代碼)

前幾天學到在 Partial View 使用 ViewBag 的一則眉角,寫成筆記備忘。

我在 ViewBagTest.cshtml 裡宣告 ViewBag.Num = 123:

@{
    Layout = null;
    ViewBag.Num = 123;
}<!DOCTYPE html><html><head><title>ViewBag Text</title></head><body><div><div>V1: Num=@ViewBag.Num</div>
        @Html.Partial("_PartialView")<div>V2: Num=@ViewBag.Num</div><div>V2: Text=@ViewBag.Text</div></div></body></html>

ViewBagTest.cshtml 使用 Html.Partial("_PartialView") 引用 _PartialView.cshtml,在其中將 ViewBag.Num 改成 456,並另外指定 ViewBag.Text = "Test":

<div style="background-color: #ddd; padding: 6px;"><div>P1: Num=@ViewBag.Num</div>
    @{
        ViewBag.Num = 456;
        ViewBag.Text = "Test";
    }<div>P2: Num=@ViewBag.Num</div><div>P2: Text=@ViewBag.Text</div></div>

請問,ViewBagTest.cshtml 在執行 Html.Partial("_PartialView") 之後,讀取到的 ViewBag.Num 與 ViewBag.Title 為何?

答案是 ViewBag.Num == 123,ViewBag.Text == null。

依此實驗推論,在 View 設定的 ViewBag 屬性會傳入 Partial View,但 Partial View 對 ViewBag 所做的修改則不會傳回 View 端。如此就有定論了嗎?

且慢,再看一個實驗,我們改設 ViewBag.List = new List<string>():

@{
    Layout = null;
    ViewBag.List = new List<string>()
    {"View"
    };
}<!DOCTYPE html><html><head><title>ViewBag Text</title></head><body><div><div>V1: List=@string.Join(",", ViewBag.List.ToArray())</div>
    @Html.Partial("_PartialView")<div>V2: List=@string.Join(",", ViewBag.List.ToArray())</div></div></body

在 _PartialView.cshtml 新增字串到 ViewBag.List:

<div style="background-color: #ddd; padding: 6px;">
    @{
        ViewBag.List.Add("Partial");
    }</div>

測試結果如下。這次 View 端成功讀到 Partial View 塞入 ViewBag.List 的內容:

以上兩個現象,說穿了是 Value Type 與 Reference Type 特性使然。(延伸閱讀:Self Test - Value Type vs Reference Type ) 而背後的原因是 - ASP.NET MVC 會為 Partial View 複製一顆專屬的 ViewBag,但因為 Reference Type 特性,複製版 ViewBag 與原始 ViewBag 的 Reference Type 屬性是同一個物件個體。

由 Github 上的 ASP.NET MVC 原始碼可驗證這點:(是的,ASP.NET MVC 的原始碼已經完全在 Github 公開了,Open Source 萬歲!)

internal virtual void RenderPartialInternal(string partialViewName, ViewDataDictionary viewData, 
object model, TextWriter writer, ViewEngineCollection viewEngineCollection)
{
	if (String.IsNullOrEmpty(partialViewName))
	{
		throw new ArgumentException(MvcResources.Common_NullOrEmpty, "partialViewName");
	}

	ViewDataDictionary newViewData = null;

	if (model == null)
	{
		if (viewData == null)
		{
			newViewData = new ViewDataDictionary(ViewData);
		}
		else
		{
			newViewData = new ViewDataDictionary(viewData);
		}
	}
	else
	{
		if (viewData == null)
		{
			newViewData = new ViewDataDictionary(model);
		}
		else
		{
			newViewData = new ViewDataDictionary(viewData) { Model = model };
		}
	}

	ViewContext newViewContext = new ViewContext(ViewContext, ViewContext.View, 
	newViewData, ViewContext.TempData, writer);
	IView view = FindPartialView(newViewContext, partialViewName, viewEngineCollection);
	view.Render(newViewContext, writer);
}

HtmlHelper 在 RenderPartial() 時會以 new ViewDataDictionary(原來ViewData) 方式另建一個 newViewData 供 PartialView 使用,這就是為什麼在 Partial View 修改數字及字串不會反映回 View 端,但 List<string> 會。

好,如果我們希望在 Partial View 裡能修改 ViewBag 的所有屬性並傳回 View 端該怎麼做?

以下是我找到最簡潔的做法,在 View 端來個 ViewBag.ParentViewBag = ViewBag,把自己當成 Reference Type 屬性傳進去:

@{
    Layout = null;
    ViewBag.Num = 123;
    ViewBag.ParentViewBag = ViewBag;
}<!DOCTYPE html><html><head><title>ViewBag Text</title></head><body><div><div>V1: Num=@ViewBag.Num</div>
    @Html.Partial("_PartialView")<div>V2: Num=@ViewBag.Num</div></div></body></html>

在 Partial View 改用 ViewBag.ParentViewBag,所有的修改就會忠實反映回 View 端囉!

<div style="background-color: #ddd; padding: 6px;">
    @{ var vb = ViewBag.ParentViewBag;}<div>P1: Num=@vb.Num</div>
    @{
        vb.Num = 456;
    }<div>P2: Num=@vb.Num</div></div>		

測試 OK,打完收工,我們下次再會。(揮手下降)

Oracle NVarChar2 可存中文字數上限問題

$
0
0

同事分享在 Oracle 踩到 NVarChar2 中文字數上限的地雷,一句話點醒我夢中人,嚇得我屁滾尿流失了魂,原來我也搞錯多年。

不囉嗦,直接看圖。

我們都知道,NVarChar2 的長度上限是 4000,而 NVarChar2 支援 Unicode,不管是中文或英數字,一個字元都算1,所以 NVarChar2(4000) 可以儲存 4000 個中文字? 錯了! 是 1,333 個,如果你試圖塞入 1,334 個中文字元,將會得到「ORA-01401 插入值過大」錯誤。

用 LEGNTH()、LENGTHB() 觀察就很清楚了。當資料庫使用 UTF8 編碼,一個中文字元算 3 個 Byte,1333 個中文耗用 3999 Byte,1334 就會破表。

官方文件有提到這點:(參見Table 6-5 Character Set Advantages and Disadvantages for a Unicode Datatype Solution)

The maximum lengths for the NCHARand NVARCHAR2 columns are 2000 and 4000 characters respectively, which is more than those for NCHAR(1000) and NVARCHAR2 (2000) in AL16UTF16. Although the maximum lengths of the NCHAR and NVARCHAR2columns are larger in UTF8, the actual storage size is still bound by the byte limits of 2000 and 4000 bytes, respectively. For example, you can store 4000 UTF8 characters in an NVARCHAR2 column if all the characters are single byte, but only 4000/3 characters if all the characters are three bytes.

總之,這是 Oracle 的設計始然,只是我誤解了十多年,用 SQL Server NVarChar 最大長度行為想當然爾。

改用 SQL Server 驗證:

NVarChar(4000) 可以放 4000 中文字沒問題,SQL Server 內部使用 UCS-2 編碼,實際儲存 Byte 數為 8000。

總論:Oracle NVarChar2 跟 SQL Server NVarChar 的最大長度都是 4000,只是 Oracle 要抓字串轉為 byte[] 後的長度,依編碼而異,放中文或英數字的字數上限不同,若為 UTF8 純中文可放 1333 個字元;SQL Server 則是抓字串字數,可放足 4000 個中文字元。

如何自訂 OpenCC 字彙轉換表

$
0
0

OpenCC 已提供十分優質的繁簡轉換,不過呢,實際使用下來難免會有些不到位的地方。所幸,OpenCC 的架構開放又有彈性,修改 json 設定檔就能載入自訂轉換字典,如果對既有轉換表或轉換規則不滿意,OpenCC 開放源碼,絕對讓你改到開心為止。

用個簡單例子示範如何自訂字彙轉換。假設我想將「黑暗執行緒在雲霄飛車上吃便當」翻成簡體,如使用包含常用詞彙轉換的設定檔 tw2sp.json,轉換結果如下:

輸出結果為「黑暗綫程在云霄飞车上吃便当」,而我希望保留「執行緒」不要翻成「綫程」,並將「云霄飞车」與「便当」 翻成大陸用語「过山车」與「盒饭」。

遇到 OpenCC 未包含的轉換字彙,最簡單的解決方法是在 json 設定加掛自訂的轉換表。如下圖,每行文字以 Tab 鍵相隔,前方是繁體中文詞彙,後方是希望轉換的簡體中文詞彙:

將 tw2sp.json 另存為 my-tw2sp.json 再修改加入{ "type": "text", "file": "TWCustMapping.txt" }:

改用 my-tw2sp.json,「过山车」與「盒饭」對了,但「綫程」沒改過來:

細究原因,是 TWPhrasesRev.ocd 裡定義了將「執行緒」轉為「綫程」。要修正這點可從原始碼中找到 TWPhrasesRev.txt,新增一條專有名詞,指定「黑暗執行緒」還是翻成「黑暗執行緒」,值得注意的是 TWPhrasesRev.txt 每一行前後都是繁體。

如下圖,我們將 TWPhrasesRev.ocd 換成 TWPhrasesRev.txt,type 則改成 "text":

重跑一次,結果就完全符合預期了:

OpenCC 支援 .txt 跟 .ocd 兩種格式的字典檔,修改並改用 TWPhrasesRev.txt 即可自訂轉換詞彙,如希望提高轉換效率,可利用 opencc_dict.exe 工具將修改版 .txt 轉換成 .ocd。(注意:.ocd 檔 32/64 位元有別,請用正確位元版本的 opencc_dict.exe 進行轉換)

掌握以上技巧,我們就能微調轉換結果,符合客戶的各式要求囉~ 祝大家轉換愉快。


【笨問題】Word 使用非細明體時行距過大

$
0
0

我有個困擾多時的 Word 問題,每回將細明體、標楷體換改成微軟正黑體或其他字體時,行距會變成超大(例如以下示範):

之前我的鴕鳥做法是修改行距為固定行距,但一直不知其所以然,最近花了點時間研究才理解問題所在與正確解法。

關鍵在於 Word 預設啟用了「文件格線被設定時,貼齊格線」,勾選「檢視格線」後便可一目膫然。

平平是 12 號字,細明體及標楷體尺寸較小,恰好可以塞入兩條格線之間:

 

當選用微軟正黑體、Google 思源黑體等其他字型,同樣是 12 號字,尺寸卻超過兩條格線的高度,於是 Word 選擇三條格線放一行字並垂直置中,造成行距變得超大:

實測將字型縮小,微軟正黑體縮到 10 號,Google 思源黑體縮到 9 號,行距便與細明體或標楷體一致:

 

既然與貼齊格線有關,解法有二:

  1. 修改版面設定,設成「沒有格線」

  2. 修改段落設定,取消「文件格線被設定時,貼齊格線」

第一種做法範圍為整份文件,第二種做法需要逐段設定,實務上則可使用「設定成預設值」套用到預設範本,如此就不用每次開新文件都需調整囉~

【茶包射手日記】網頁特定連結失效疑案

$
0
0

遇上個人射手生涯數一數二的坑爹茶包...

故事是這樣的。接獲報案,有使用者投訴他換新電腦後無法點選內部網站選單的某個連結,其餘功能正常,而全公司只有這一起案例。

起初懷疑是 JavaScript 故障,實際連上使用者電腦測試並未發現 JavaScript 錯誤,而在使用 F12 偵察過程連結忽然正常,正要以「新電腦需經開光才會正常」的靈異理由結案,判定前為求謹慎再試了一次,這才發現問題未解 - 問題只出現在瀏覽器最大化時!

接著我懷疑網頁上有東西遮蔽了連結,想用 F12 開發工具檢查元素卻選不到那個無法點選的連結,而滑鼠移動到該連結也不會顯示手指圖示,我弄了一個現場摸擬還原當時情況,如下圖,滑鼠移到最右側漢堡選單理應出現手指,但並沒有:

用力觀察看出一些異樣,無法點選區域有個顏色非常淺的圓弧(見下圖,要張大眼睛才看得到),瀏覽器縮小後拖拉時該圓弧不會跟著移動,看起來是浮在瀏覽器上方。若在該區域按右鍵則更明顯,跳出陌生選單:

一查之下,原來桌面右上角藏了一個名為 ClocX 的桌面時鐘,其透明度被設到很低並指定浮在桌面最上層(Always on top),且關閉了滑鼠經過現身設定(Mouse-Over Transparent)... 如果這不是坑人,什麼是坑人呢?

關閉透明效果,真相大白!

為什麼使用者自己裝了這種東西卻不知道?透明度調那麼低是哪招?這樣搞應該一堆軟體卡到陰,為何要輪到我來射茶包?

嗯,我肯定就是那個「被選中的人」,好一個「靠盃的考驗」!

LibreOffice docx 轉 pdf 評估筆記

$
0
0

我有寫了一個 Word 套表服務,最早是用 C# 呼叫 Word執行置換及轉 PDF,後來改走 OpenXML SDK,罝換速度快了五倍以上,唯獨轉 PDF 這段還只能仰賴 Word 完成。從 ASP.NET 呼叫 Word Application 會受限執行身分權限過低,Word 程序的生命周期亦較難掌控,最後我決定寫成 Windows Service,以特定登入帳號啟動固定數量的 Word 程序,以 Web API 方式接收並平均消化套表需求。做法可行且運行了一陣子,但有以下缺點:

1.    套表服務為獨立 Windows Service,當網頁執行套表出錯,追查範圍變大,追查路徑較複雜
2.    必須在伺服器安裝 Word,且為配合背景執行有額外設定步驟
3.    Word 為前景程式,以 Windows Service 長期執行時偶爾會出鎚,需人工介入排除

因此,我老惦記住想找到不需 Word 也能將 DOCX 轉 PDF 的替代方案,攻下「非 Word 不可」的最後一里路,把整個套表轉 PDF 作業都搬進 ASP.NET 簡化系統架構。

DOCX 轉 PDF 有不少商業元件選項,但一致特色是大多價格不斐(數百至數千美金不等),且是否能精確轉換亦需驗證。所以我決定先從免費開源解決方案看起,LibreOffice是最佳選擇,它延續 OpenOffice 的使命,提供跨平台的
免費開放文書軟體,社群很活躍持續有更新,對 MS Office 格式支援也夠完整。

LibreOffice 有安裝程式,但實測 Copy 檔案也能部署,使用命令列 soffice  --headless --convert-to pdf filename.docx 即可將 .docx 轉 .pdf,另外驗證可在 ASP.NET 直接呼叫執行沒問題。(Github 有相關元件可以省工) 餘下的問題只剩相容性 – LibreOffice 讀取 DOCX 轉 PDF 是否能保有 Word 編譯格式,決定其可接受度。

為此我做了一次批次測試,請同事提供五百多個線上實際使用的套表範本,以 ASP.NET 程式呼叫 LibreOffice 批次轉成 PDF。除了一個檔案有問題需要手工前置轉檔(先存 doc 用 LibreOffice 開啟另存 docx,很無厘頭但有效),其餘檔案都成功轉成 PDF,每次耗時在 3 秒內,速度很 OK。

而最關鍵的問題 – 轉出的PDF排版有跑掉嗎?有壞消息也有好消息。

壞消息是大約有六至七成出現程度不一的排版差異,有些只影響美觀,有些影響閱讀必須調整。
好消息是所有排版差異都有解法,使用者必須改變習慣或重新修校,但沒有遇到無解項目。

整理遇到的問題典型如下:

  1. 簽章圖檔只有部分顯示
    原因:圖檔被貼在表格儲存格裡。
    解法:剪下改貼至文件本體,文繞圖改為「文字在後」即可
  2. 表格框線消失、變粗
    原因:多為 Word 重複設定框線造成(上欄設了下框線,下欄位又設上框線),在 Word 合併只顯示一條,但 LibreOffice 兩條都顯示會變成粗黑線。
    解法:重新調整框線可修正
  3. 表格行高不足,文字下半截消失 
    原因:在 Word 指定不足以放入字型大小的固定列高,Word 會自動撐大但 LibreOffice 不會
    解法:修改為合理列高
  4. 段落下半部被遮蔽
    原因:段落設定了過小的固定行高,Word 會強迫撐大但 LibreOffice 不會
    解法:調為單行間距
  5. 簽章圖檔變形(雙倍寬度且只顯示左半邊)
    原因不明,將圖檔另存GIF重新插入後恢復正常
  6. 頁首圖檔錯位
    原因是插入的圖檔未經修剪,幾與紙張同寬,在 Word 裡部分已超出頁面範圍。而 LibreOffice 不允許圖檔超出範圍,導致圖案左移,裁剪圖檔至適當尺寸可解決。
  7. 頁首、頁碼跑位
    原因:使用未使用 Word 頁首功能,是自己算位置放標題、頁碼,LibreOffice 排版結果與 Word 有出入位置就錯了
    解法:回歸使用頁首、頁尾正統做法
  8. 表格浮至上方並覆蓋文字
    原因為 Word 排版抓太緊,LibreOffice 與 Word 顯示位置有誤差,需加入緩衝區
  9. docx 無法使用 LibreOffice 開啟
    五百多份文件僅有一例,原因不明。
    Workaround 為 Word 先改存 doc,交給 LibreOffice 開啟再另存回 docx,過水後問題排除。

【結論】

使用 LibreOffice 取代 Word 轉 PDF 作業,直接在 ASP.NET 執行是可行的。但改用第三方轉換軟體排版結果難免有所出入(實測差異性比原本想像來得大),需使用者配合在上傳範本前自行調整 Word 文件確認輸出 PDF 正常。最簡單的做法是請使用者安裝 LibreOffice,上傳前自行檢查試轉 PDF 目視確認,另外還需提供 FAQ 引導遇到輸出不一致時如何調整。與現行使用者可無腦使用 Word 任意修改文件都不用擔心出錯不便,將影響使用者接受度。至於二者輸出結果差異有些屬於對文件規格實作的見解不同,無關對錯,嘗試其他文書轉換元件應也會面臨相似問題,只差在嚴重程度有別。若要求零誤差,使用 Word 才能保證萬無一失。

小技巧 - ASP.NET MVC 限定 POST 但開放本機 GET

$
0
0

分享 ASP.NET MVC 私房小技巧一則。

AJAX 呼叫 ASP.NET MVC 時,基於安全考量應限定 POST 方法。(參考:隱含殺機的GET式AJAX資料更新 - 黑暗執行緒)
不過在開放測試階段,開放 GET 可在瀏覽器網址列輸入 URL 測試較方便,有沒有兩全其美的方法?

於是我寫了一個 Action Attribute,實現「從 localhost 呼叫可用 GET,從正常 IP 存取只能 POST」的效果,像是這樣:

HomeController 的 Test Action(),瀏覽器透過 localhost 可讀取,改用實際 IP 則傳回 HTTP 404。應用方法很簡單,在 Action 加上 [LocalHttpGetOrHttpPost] 即可,基於安全考量,預設只開放 POST,必須在 appSetting 加入 <add key="EnableRestrictedGet" value="true"/> 才開放本機使用 GET :

[LocalHttpGetOrHttpPost] 
public ActionResult Test() 
{ 
    return Content("Test OK"); 
} 

為求彈性起見,再多支援透過 appSetting 指定開放 GET 存取的 IP 清單。例如:

[RestrictedHttpGetOrHttpPost("TestIps")] 
public ActionResult Test() 
{ 
    return Content("Test OK"); 
}

設定檔需加入對應的 IP 清單:

<appSettings> <add key="EnableRestrictedGet" value="true"/> <add key="AllowGetIPs" value="::1,127.0.0.1,172.28.1.1"/> </appSettings>

完整程式範例如下,有需要的同學請自取:

using System; 
using System.Collections.Generic; 
using System.Linq; 
using System.Reflection; 
using System.Web; 
using System.Web.Mvc;

namespace System.Web.Mvc 
{ 
    public class LocalHttpGetOrHttpPostAttribute : RestrictedHttpGetOrHttpPostAttribute 
    { 
        public LocalHttpGetOrHttpPostAttribute() : base("localhost") 
        { 
        } 
    }

    public class RestrictedHttpGetOrHttpPostAttribute : ActionMethodSelectorAttribute 
    { 
        //REF: https://github.com/mono/aspnetwebstack/blob/master/src/System.Web.Mvc/HttpGetAttribute.cs 
        private static readonly AcceptVerbsAttribute getCheck = new AcceptVerbsAttribute(HttpVerbs.Get); 
        private static readonly AcceptVerbsAttribute postCheck = new AcceptVerbsAttribute(HttpVerbs.Post);

        private static readonly bool EnableRestrictedGet = 
            System.Configuration.ConfigurationManager.AppSettings["EnableRestrictedGet"] == "true";

        private readonly string[] AllowedGetIPAddresses = "::1,127.0.0.1".Split(',');

        public RestrictedHttpGetOrHttpPostAttribute(string allowedIpSettingName) 
        { 
            if (allowedIpSettingName != "localhost") 
            { 
                var allowedIps = System.Configuration.ConfigurationManager.AppSettings[allowedIpSettingName]; 
                if (string.IsNullOrEmpty(allowedIps)) 
                    throw new ArgumentException($"appSetting '{allowedIpSettingName}' not found!"); 
                AllowedGetIPAddresses = allowedIps.Split(',', ';'); 
            } 
        } 
        public override bool IsValidForRequest(ControllerContext controllerContext, 
            MethodInfo methodInfo) 
        { 
            return 
                postCheck.IsValidForRequest(controllerContext, methodInfo) || 
                EnableRestrictedGet && 
                getCheck.IsValidForRequest(controllerContext, methodInfo) && 
                AllowedGetIPAddresses.Contains(controllerContext.HttpContext.Request.UserHostAddress); 
        } 
    } 
}

使用 Headless Chrome 擷圖、轉存PDF、爬資料

$
0
0

Chrome 自 59 版起內建了 Headless 模式,允許透過命令列啟動 Chrome 以無 GUI 方式執行,具備與正常開啟完全相同的網頁渲染及 JavaScript 引擎,還可透過網路連線遙控。這個功能可以用於不少有趣應用,這裡列舉幾種實用情境。

註:Headless Chrome 的完整參數可參考 List of Chromium Command Line Switches « Peter Beverloo

網頁擷圖

將網頁存成圖檔或 PDF,過去我是用 PhantomJs。Headless Google 的出現,能取代 PhantomJS 功能且更快更穩,讓 PhantomJS 作者決定停止辛苦的獨力維護工作,PhantomJS 的Github 專案也已封存,故改用 Headless Chrome 已成定局。

要使用 Headless Chrome 擷圖很簡單,在命令列工具輸入:
"c:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --screenshot=E:\Chrome\test.png --disable-gpu --window-size=320,568 https://www.microsoft.com

即可取得圖檔如下:

很簡單吧?

如要模擬行動裝置,則可加入--user-agent="…" 指定User-Agent。例如 Google 網站預設是以桌上電腦解析度呈現,遇直式小螢幕解析度不會自動切換:
"c:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --screenshot=E:\Chrome\test.png --disable-gpu --window-size=320,568 https://www.google.com.tw

擷圖時只能看到部分內容:

遇此狀況,可加上--user-agent="" 傳入 iPhone 5/SE 的 User-Agent 字串克服:

"c:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --screenshot=E:\Chrome\test.png --disable-gpu --window-size=320,568 --user-agent="Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1" https://www.google.com.tw

加上 User-Agent 後,Google 網站改回傳行動裝置專屬排版(底下還有 App 安裝提示):

 

網頁另存PDF

使用參數--print-to-pdf 可模擬瀏覽器列印功能,將網頁存成PDF,例如:

"c:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --print-to-pdf=E:\Chrome\test.pdf --disable-gpu http://www.microsoft.com

 

線上偵錯

遇到 Headless Chrome 行為未如預期時,一樣可用 F12 開發者工具偵錯,做法是透過--remote-debugging-port=xxxx 參數開啟線上偵錯功能:

"c:\Program Files (x86)\Google\Chrome\Application\chrome.exe" --headless --remote-debugging-port=9222 --disable-gpu http://www.microsoft.com

啟動 Headless Chrome 後,再另開一個 Chrome 連上 localhost:9222,可得到已開啟網頁清單:

點選後即可比照一般網頁使用 F12 開發者工具偵錯:

 

網頁爬蟲、自動測試

除了擷圖跟存 PDF,我們也可以寫 JavaScript 程式操作 Headless Chrome 執行較複雜的動作,很適合用來執行自動測試或擷取網頁內容。要透過 JavaScript 操作 Headless Chrome,需借助一個 Node.js 程式庫 - Puppeteer。 開始之前需先安裝 Node.js,再使用 npm 安裝 Puppeteer。(註:安裝 Puppeteer 預設將一併安裝 Chromium 執行檔,如想直接使用 Chrome 不額外下載,可使用指令 env PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true npm i --save puppeteer 參考)

Node.js 支援 ECMAScript 6,跟我平常網頁在寫的 JavaScript 寫法大異其趣。但我爬了幾篇文章,倒是也能寫出 Google 查詢關鍵字並抓回搜尋結果的簡單程式。(補充:Puppeteer API 文件 https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md )

程式範例如下:

const puppeteer = require('puppeteer');

(async() => {
const browser = await puppeteer.launch({
	executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
});
const page = await browser.newPage();
//連上Google搜尋網頁
await page.goto('https://www.google.com.tw', {waitUntil: 'networkidle2'});
var res  = await page.evaluate(() => {
	document.querySelector("input[type=text]").value = "darkthread";
});
//點選並等待結果
await page.click("input[value='Google 搜尋'][type=submit]");
await page.waitForNavigation({ waitUntil: 'networkidle0' });
//擷取影像
await page.screenshot({path: 'result.png'});
//擷取搜尋結果傳回JSON
res = await page.evaluate(() => {
	return [].slice.call(document.querySelectorAll("#ires div.g h3 a"))
	.map((a) => { return { "text": a.innerHTML, "link": a.href }; });
});
console.log(res);
await browser.close();
})();

實測如下:

 

工具箱再添新武器一件。

修改 NTFS 權限會改變檔案修改日期嗎?

$
0
0

修改 NTFS 權限、變更檔名會改變檔案修改日期嗎?

這問題乍聽之下無關緊要,在射茶包過程卻可能是左右偵辦方向的關鍵,有追究到底的必要。

今天遇到一起案例。同事報案,某個運行多時的網站忽然故障,由錯誤訊息懷疑是系統無法從設定檔讀取連線字串,但檢查過「設定檔沒有被修改的痕跡」,格外離奇。注意到了嗎?(謎:你都加了「」,要不注意很難吧?)一般我們判斷檔案是否被修改,主要會依據檔案修改日期,而這裡隱藏了一個假設 - 檔案只要被更動修改,就一定會反映在檔案修改日期上。

事實不然,例如這次的網站故障,最後查出來是 AppPool 身分對設定檔的 NTFS 讀取權限被移除造成的,而該設定檔的最後修改日期與去年上線時間吻合,差點被當成不在場證明讓犯人逍遙法外。

為求加深印象,我做了以下實驗:證明修改 NTFS 權限及檔案更名都不會改變檔案的最後修改時間(一直是下午 09:04:23)。

補充:除了修改權限、檔案更名不會改變檔案修改日期,檔案修改日期可透過 Windows API 修改,而複製或解壓縮覆寫檔案時也可能被置換成來源檔的修改時間。故在判斷檔案是否有異動時,不宜單依修改日期做判斷。

Coding4Fun–網頁遙控可動式樹莓派相機

$
0
0

前陣子入手 3D 印表機,從網路下載現成模型幫老古董 Raspberry Pi B+ 印了外個殼,一時懷舊之心大發,翻出舊零件拼裝了一台可轉動鏡頭角度的網路照相機,還騷包寫了網頁版控制介面,摸到一大票新東西,筆記留念一下。

影片

Raspberry Pi 的硬體 IO 介面不如 Arduino 豐富,能控制伺服馬達的 PWM 輸出只有一組,想控制多個伺服馬達通常會外接 I2C 介面控制板,前陣子玩 Arduino時入手一塊 PCA9685 16 路舵機控制板還沒拆封,這回正好派上用場。(下圖前方的長方形藍色電路板就是)

PCA9685 控制板用 4 條線連上 Raspberry Pi,二者使用 I2C 協定溝通,讓 RPi 可控制多達 16 個伺服馬達。而攝影鏡頭旋轉座只需兩個,一個水平旋轉,一個控制仰角。下圖的黑色 2 軸塑膠雲台也是之前買的,現在雖然又多了自己 3D 列印選項,但規格已標準化的東西,大量生產的成品還是遠比自幹省時省力也省錢。

Raspberry Pi 的程式開發資源以 Python/C 為大宗,Pi B+ 沒法玩 .NET Core,就用 Python 吧! 反正這年頭什麼語言都可以用 Visual Studio Code 寫。 XD

照著範例改寫出一支 servo.py(註:Python 有個奇妙特色,縮排是有意義的不能隨興亂調,寫慣 C#/JavaScript 有點不適應 ),藉由 python survo.py x y控制水平/垂直伺服馬達角度,拍照則用 raspistill -o photo.jpg –w 640 –h 480,不過 Raspberry Pi 拍照超慢,拍一張照片耗時 5 秒,調參數可縮短曝光時間,但照片亮度會嚴重偏暗。無論如何,到這裡一台由命令列控制的可轉鏡頭照相機算是完成了。但,身為前端攻城師,沒提供網頁操作介面像話嗎? 於是,我又踏上另一段奇幻旅程...

前面說過我的樹莓派是古老的 B+ 版,最新版 ASP.NET Core 不是選項,回頭花時間鑽研 Mono 投資效益不佳。要在 Pi 寫網頁,Apapche 與 PHP 有現成的套件可裝,一個 sudo apt-get  install … 指令就順利搞定,那就用 PHP 來寫網站好了。

爬文惡補 PHP,透過 URL 控制伺服馬達及拍照是靠底下這一小段程式搞定,程式接收參數並呼叫外部程式,透過 Python 程式旋轉鐘頭,使用 raspistill 拍照。

<html><?php
$h = intval($_GET['h']); //200-600, mid 400
$v = intval($_GET['v']); //100-500, mid 350
if ($_GET['m'] == "R") {
    $h = 400;
    $v = 350;
}

if ($h > 0 || $v > 0) {
    if ($h == 0) 
        $h = -1;
    else {
        $h = max($h, 200);
        $h = min($h, 600);
    }
    if ($v == 0) {
        $v = -1;
    }
    else { 
        $v = max($v, 150);
        $v = min($v, 500);
    }
    $cmd = sprintf("sudo python /home/pi/python/servo/Adafruit_Python_PCA9685/examples/runservo.py %d %d", $h, $v);
    $result = exec($cmd, $output);
}
if ($_GET['m'] == 'S')
    exec('sudo raspistill -w 640 -h 480 -o /home/pi/php/webcam/photo/current.jpg -n -ex auto -awb auto');
?></html>

在陌生程式領域裡探險,資深老鳥重溫菜鳥心境,意外有個感想:

避免寫出不安全的程式絕對是必要,但對菜鳥還真是一項考驗。

依據多年的開發經驗,我一眼看出裡面隱藏了資安風險:一個是將使用者輸入內容轉為呼叫外部程式參數有指令碼注入風險,二則是開放網頁程式以管理者身分執行,一旦程式有漏洞,損害程度加劇。

前者倒不難處理,只需強制將參數轉成數字,即能社絕偷渡 Shell 指令。至於後者,較好的做法是另寫一個本機服務,透過 IPC / Socket 接收指令做事,如此網站使用低權限執行身分即可。這種中間服務如在 Windows / C# 我是信手拈來,如今身處 Raspberry Pi 環境就難如登天了,只能先接受這種可行但不安全的解決方案。實務上,應該有不少的安全漏洞也在類似情境下誕生,更何況,有更多情況是開發者壓根沒意識某些做法或寫法是不安全的... (尤其是無任何開發經驗的正港菜鳥) 

PHP 搞定後,前端我用 SPA 來做,總算又回到我熟悉的領域,不過我決定用 Vue.js 當成練習,強迫自己跟它培養感情。

我在 Raspiberry Pi 設定 Samba 分享程式相關資料夾,Python、PHP、Vue.js 都是在 Windows 10 用 Visaul Studio Code 連上網路磁碟機修改,開發體驗還不錯。

影片部分,我又順手練習簡單後製還假掰配上 YouTuber 經典 BGM,RPi 相機的速度太慢,不過拿來拍縮時影片應該OK,至於監控用途,另外要走 Streaming 模式,下回再研究。總結是一次綜合格鬥練習,用上了 Ubuntu、GPIO、I2C、Python、PHP、Vue.js、影片後製... 呵。

謝謝收看。

【參考資料】


VS2017 開啟專案找不到 System.Net.Http 參照

$
0
0

某個使用 Microsoft.AspNet.WebApi.SelfHost 4.0.20918 NuGet Package 的 Console Application 專案使用 VS2017 開啟時出現 System.Net.Http 及 System.Net.Http.WebRequest 參照失效,改用 VS2015 開啟則無此問題。

VS2017 開啟失敗但 VS2015 正常的狀況之前遇過(VS2017 無法載入 MVC4 專案),但這回發生在 Console Appliation 專案,狀況不同。

優先檢查導致 NuGet 參照失敗的頭號戰犯 - .csproj 的 HintPath 設定 - 發現 System.Net.Http 的 HintPath 有正確指向 packages 目錄:

    <Reference Include="System.Net.Http, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
      <HintPath>..\packages\Microsoft.Net.Http.2.0.20710.0\lib\net40\System.Net.Http.dll</HintPath>
    </Reference>

而 packages\Microsoft.Net.Http.2.0.20710.0\lib\net40\System.Net.Http.dll 確實存在,但我留意到有另一個目錄 net45,底下有個 _._ 空白檔案。

爬文學到新知識,_._ 檔案是用來確保 net45 資料夾在 ZIP 壓縮時不會被略過,而沒放 DLL 檔的 net45 資料夾代表 Package 相容於 .NET 4.5,但組件已內建於 GAC 不需額外複製安裝:

This is important because the existence of an "empty" net46 folder means that the package supports .NET Framework 4.6, but does not require any assemblies (DLLs) in order to run on that version of .NET. This is most likely because the implementation of the package is in the GAC.

專案為 .NET 4.5.2,故應改用 .NET 4.5+ 內建的 System.Net.Http.dll,換句話說,是參照 GAC 的 System.Net.Http 出了問題。回想專案升級 4.5.2 是很久以前的事,而升級後曾用 VS2017 編譯過,何以忽然不行?

鎖定關鍵字查到兩篇相關討論:

得到結論:這是 VS2017 15.5.x 的 Bug (我的 VS2017 版本是 15.5.7),將會在 VS2017 15.5.8 修正(目前進入 Preview 階段)。在修正釋出前,最簡單的 Workaround 是將 .csproj <Reference Include="System.Net.Http"> 及 <Reference Include="System.Net.Http.WebRequest"> 的 HintPath 刪除,即可藥到命除。

閒聊 - RWD、React Native、Xamarin、Cordova,一魚兩吃到底行不行?

$
0
0

RWD、React Native、Xamarin/Cordova,一魚兩吃到底行不行?

前陣子 Airbnb 發表了系列文章,訴說其在傾力投入兩年之後忍痛放棄 React Native 的心路歷程,來自資深用戶的親身心得特別有參考價值。原文為英文長篇,Oursky (一家創立於香港的Web / Mobile 產品開發工作室) 佛心整理了中文摘要: Airbnb: 們一起寫過的 React Native

很巧,前陣子剛好也被問了幾次:網站該用 RWD 還是做成大小網?用 Xamarin / Cordova 寫 App OK嗎?聽起來是個 FAQ,寫篇文章整理我的想法。

RWD、Xamarin、Cordova,連同 React Native / Vue Native,問題本質相同,這些技術都強調開發一次同時滿足桌機及行動裝置平台,聽起來很美好,而提問者想問的是「一魚兩吃解決方案到底行不行?」

RWD 想實現的是寫一套網頁,PC、手機與平板都能看;Xamarin 強調用 .NET 開發背景一次囊括 UWP、Android、iOS App 開發;React Native / Vue Native 則是主打重複利用 JavaScript 打造網站跟 App。終極目標都在強調節省開發成本,而天下沒有白吃的午餐,節省開發成本的同時,犧牲掉一部分使用者體驗的最佳化。

這類一魚兩吃方案猶如瑞士刀,小小一把可權充鋸子剪刀起子開罐器,但就只是「權充」,其操作體驗及順手度絕對比不上正統的鋸子剪刀起子開罐器。要一魚多吃,勢必在體驗最佳化上有所折衷將就。

但程式跟實體工具有很大一點不同,軟體不受物理空間限制,你很難將二十種不同規格的起子塞進一把瑞士刀,但在程式上是可能的。因此,軟體追求最佳化到極致就是針對各不同情境各寫一套,再塞進同一支程式裡,但如此已失去重複利用開發結果的初衷;要回到初衷,得從各式情境的設計異中求同,找出可共用部分。當情境一多,設計愈複雜,要想出可跨情境共用的模組、元件的難度就愈高,架構複雜度也跟著上升。到頭來,還不如不求共用,針對不同情境各寫一套來得單純,開發成本還比較低。

因此,我認為 RWD、React Native、Xarmarin/Cordova 等解決方案值不值得採用,能不能省成本,決策曲線會如下圖。

當需求單純,使用者隨和肯將就,一魚兩吃方案可節省大量開發成本;一旦需求變得複雜刁鑽,使用者龜毛,要維持開發核心共用又要滿足規格的難度便會急劇上升,終究出現交叉 - 一魚兩吃為了同時滿足兩種平台絞盡腦汁,還不如各寫一套來得單純省事。

不管 RWD、React Native、Xamarin,這個成本交叉原則都相同,差別在各技術議題與應用情境下的斜率、曲線形式不盡相同,值不值得採用得依個案而論。但至少有一點可以銘記在心,當聽到有人鼔吹 RWD、React Native、Xamarin/Cordova 這類一魚兩吃方案講得天花亂墜之際,別傻傻相信世上真有銀彈(Silver Bullet)能一勞永逸解決任何問題,任何技術決策都是取捨,該不該買單請依實際狀況評估決定。

閒聊 - Web API 是否一定要 RESTful?

$
0
0

傳說 C 語言風格(C#/Java/JavaScript…)程序員依其信仰分為兩大派,自古以來不共戴天:

依我的觀點,寫 WebAPI 的程序員也分成兩派,RESTful 派跟非 REST 派。我屬於後者,是非主流的少數派。

前幾天跟同事聊到 Web API 是否一定要 RESTful,三言兩語說不清,寫篇文章梳理思緒好了。

RESTful API 是指實踐 REST Representational State Transfer精神的 API 設計風格,其核心精神在於借用 HTTP 協定做為基礎,讓 API 規格簡單一致,大致有以下特色 :

  • 透過 URI 指定要存取或操作的資源
  • 可使用 QueryString,但只應拿來傳遞額外過濾條件或參數,不應包含識別資源的鍵值
  • 使用 HTTP 方法 POST、GET、PUT、DELETE 對應到建立、讀取、更新、刪除等動作。
    也有人主張 PUT 是 Relace (Create 或 Update),另外增加 PATCH 用於部分更新( Partial Update )
  • 透過 Accept Header 指明可接收的內容格式,例如:XML 或是 JSON
  • 伺服器透過 HTTP 狀態碼回傳執行結果,例如:200 成功、401 存取被拒、404 找不到資源、500 伺服器錯誤

而 REST 概念的提出者 Roy Fielding是 HTTP 規範的主要作者及 Apache HTTP Server 專案的發起人之一,這也是讓 RESTful API 風格備受推崇的原因之一。

延伸閱讀

採用 RESTful API 最大的好處是風格統一,API 名稱簡潔(不會冒出一堆 QueryThese、UpdateThat、DeleteBlah,動詞隱藏在 HTTP Method ),靠直覺及經驗就能快速上手;除錯時也可由 URL、HTTP 方法及傳回狀態直接解析各項操作的意義及結果。多年來,RESTful 設計已是 Web API  設計的主流,例如:ASP.NET MVC Web API 即是走 RESTful 風格,當在專案新增繼承 ApiController 的 API 類別,預設需實作 Get()、Post()、Put()、Delete() 方法以對映 HTTP GET、POST、PUT、DELETE 等動作。(註:當然,你也可以額外定義路由或使用 [Route("actionName")] 與 [HttpPost] Attribute 加入自訂方法,但要小心濫用會違反 RESTful 精神) ApiController 實做範例可參考:建置使用 ASP.NET Web API 的 RESTful Api - Microsoft Docs

RESFful API 是當今主流,伴隨而來的好處是相關資源豐富(Visual Studio 直接支援,還有自動為 REST API 產生說明文件及測試程式的 Swagger等),無疑是好物,但不幸地,我對它沒有愛。

實做過幾次 ,我最後選擇回歸使用一般的 ASP.NET MVC Action 實做 Web API,不使用 MVC 提供的 ApiController 機制。身為 KISS (Keep It Simple, Stupid) 法則的忠實信徒,說穿只有一項考量 – 簡單!

貫徹 RESTful 精神是件麻煩事,導致在設計 API 介面時會受到諸多限制。舉一個最簡單的例子:我想要刪除四本書,BookId 分別為 9,5,2,7,若要求依循 RESTful 精神,做法就蠻分歧的:

  1. 跑迴圈 DELETE /books/9, DELETE /books/5, DELETE /books/2, DELETE /books/7 (絕對符合 RESTful,但有點蠢...)
  2. 先透過 POST /books/selections 將 9,5,2,7 四本書打包,賦與唯一資源代碼,例如 /books/pack32767,再 DELETE /books/pack32767 參考
  3. Amazon S3 REST 的做法是 POST /?delete,傳入包含要刪除項目識別碼的XML
  4. Facebook Graph API、Parse Server REST API、Google Drive REST API 則採用將多個 DELETE 作業打包成 JSON 放在一個 Request 裡送出,在伺服器收到後再解開一一執行。 參考
  5. 也有人主張 DELETE /books/9,5,2,7 之類的做法,但如此有沒有違背 REST 精神? 我不知道

除此之外,像是混合多個異種資源的更新 URI 該怎麼取?無明確資源對象的作業 URI 該用誰? 用 MVC Action 實做只要取個能望文生義的 Action 名稱,定義好傳入參數及傳回結果就可搞定,一旦被要求符合 RESTful,難度瞬間上升,發生次數多了,RESTful 帶來的優勢是否足以彌補額外增加的成本?到頭來有可能早已無關優劣利弊,流於「當然要 RESTful,不然別人會以為我們不懂」。

而另一方面,要配合 RESTful API,JavaScript 呼叫時也變得較複雜,需要自訂 HTTP Method,解析 HTTP Status Code,雖不是大事,但不能用最簡單的 $.get()、$.post() 搞定,測試偵錯變得麻煩些 。

上述提到種種問題,其實都能找到解,不然 RESTful API 如何能走到今天? 回到前面提到刪除多本書的例子,我總覺得原本單純可用 MVC Action DeleteBooks(string[] bookIds) 就能搞定的事,為了符合 RESTful 得大顯神通,有違 KISS 精神。

基於以上考量,設計 Web API 時我習慣寫成一般 ASP.NET MVC 方法而不用 ApiController,並一律限定 POST (多少降低一些 XSS 風險,參考:隱含殺機的GET式AJAX資料更新 ),執行結果無論成功失敗都傳回統一的 ApiResult 型別件:

    /// <summary>
    /// API呼叫時,傳回的統一物件
    /// </summary>
    public class ApiResult
    {
        /// <summary>
        /// 執行成功與否
        /// </summary>
        public bool Succ { get; set; }
        /// <summary>
        /// 結果代碼(0000=成功,其餘為錯誤代號)
        /// </summary>
        public string Code { get; set; }
        /// <summary>
        /// 錯誤訊息
        /// </summary>
        public string Message { get; set; }
        /// <summary>
        /// 資料時間
        /// </summary>
        public DateTime DataTime  { get; set; }
        /// <summary>
        /// 資料本體
        /// </summary>
        public object Data { get; set; }

        public ApiResult()
        {
        }

        /// <summary>
        /// 建立成功結果
        /// </summary>
        /// <param name="data"></param>
        public ApiResult(object data)
        {
            Code = "0000";
            Succ = true;
            DataTime = DateTime.Now;
            Data = data;
        }

        /// <summary>
        /// 建立失敗結果
        /// </summary>
        /// <param name="code"></param>
        /// <param name="message"></param>
        public ApiResult(string code, string message)
        {
            Code = code;
            Succ = false;
            this.DataTime = DateTime.Now;
            Data = null;
            Message = message;
        }
    }

傳回型別統一,前則可撰寫共用 AJAX 呼叫函式,先統一處理錯誤再將資料拋回原呼叫端,錯誤代碼依系統別統一管理,在前後端產生對應。這樣的做法實際跑過幾過多個專案,沒有遇到什麼大問題,看來是可行的。(API 說明文件及測試程式我是自幹程式產生器搞定,必須承認不能套用現成工具額外多花了工夫,但量身訂做的西裝格外合身,整體上仍屬值得)

總體來看,我偏好用 ASP.NET MVC 寫 Web API 不走 RESTful ApiController,在這個議題上,應該會繼續非主流下去。

ASP.NET Core 練功筆記 1

$
0
0

也差不多該開始玩 ASP.NET Core 了。最近剛好有個適合練功的題材,拿了 ASP.NET Core + Vue.js 上場演練,將一路上參考到的資源及瑣碎心得理成筆記備忘。

  1. 關於 ASP.NET Core,MVP John Wu 有一系列 IT 鐵人文,是新手上路很不錯的參考:
    [鐵人賽 Day01] ASP.NET Core 2 系列 - 從頭開始 - John Wu's Blog
  2. 如果你習慣 IDE 開發不喜歡下指令,那麼 Visual Studio 仍是開發 ASP.NET Core 的首選,有可能從建立專案、寫程式到測試都不必動用命令列視窗。不過如果你看中的是 .NET Core 跨平台優勢,那麼學習使用 dotnet 命令列指令是必修課程。
    用命令工具建專案沒想像中複雜,例如以下幾個步驟就能建好一個可執行的空白 ASP.NET Core 網站:(要建立包含 MVC Controllers/Views 基本結構專案的話,則是用 dotnet new mvc)
  3. 免費且跨平台的 Visual Studio Code(簡稱VSCode),有愈來愈流行的趨勢,尤其涉及前端開發時,VSCode 已是主流,網路參考資源豐富超多,如果想學習進階的前端開發,無可避免得將 VSCode 列為必修課程。
  4. 簡短試用比較過 VSCode 跟 Visual Studio 2017,二者是不同量級的開發工具,各有其專長。
    VS2017 對 ASP.NET Core 的支援十分完整(記得要更新到最新 Update),具備像是 Add View、Go To View等便捷功能,跟開發 ASP.NET MVC 4/5 的體驗差不多。
    VSCode 則較像一個開發環境框架,支援程度由所安裝的擴充套件決定,想補足平日慣用的 VS2017 功能,一方面需要花時間尋找組裝,另一方面有些功能還沒有現成的擴充套件可用,故開發體驗不如 VS2017。但在 JavaScript / TypeScript 方面,VSCode 支援的完整度與時效性,以及網路參考資源的豐富程度則遠勝 VS2017。
    最後我琢磨出來較順手的做法是 - 用 VSCode 及 VS2017 開啟同一個專案,寫 MVC/C# 端時用 VS2017,寫到 TypeScript 再切換成 VSCode,各取其優勢。
  5. ASP.NET Core 的專案結構跟 ASP.NET 大不相同,已經看不到 Global.asax / web.config 這些東西,取而代之的是改用 Program.cs 起始,以 OWIN方式設定網站執行細節。也因此,ASP.NET Core 完全擺脫對 IIS 的依賴,可靠內建的 Kestrel 伺服器單獨執行,也可以搭配 IIS、Ngix、Appach 等 Reverse Proxy 伺服器對外提供服務,借重 Reverse Proxy 滿足安全防護、負載平衡、快取上的較高規格要求。
    ASP.NET Core 中的網頁伺服器實作 - Microsoft Docs
  6. 原本想在 ASP.NET Core 使用 SQLite + Dapper,不幸目前 SQLite ADO.NET 套件還不支援 Core 版本,倒是在 EntityFramework 相關支援已經齊全。順勢調整架構,也練習一下 EF 吧!
    -NET Core 使用者入門 - 新資料庫 - EF Core - Microsoft Docs
  7. .NET Core 預設不再使用 app.config 或 web.config 設定檔,取而代之是多樣化的設定保存選項,像是 INI/JSON/XML/環境變數/記憶體。我個人偏好 JSON,做法可參考 ASP.NET Core 的設定 - Microsoft Docs

未完待續...

ASP.NET Core 練習筆記 2 – Ubuntu + SQLite + Dapper

$
0
0

繼續 ASP.NET Core 專案練習,本階段的戰術目標:嘗試在 Linux Ubuntu 16.04 上跑 .NET Core + SQLite + Dapper。

  • Ubuntu 遠端桌面設定
    Ubuntu xrdp 支援使用 Windows 的「連線遠端桌面」程式(RDP Client)登入桌面環境,還可在主機螢幕操作桌面之外另開一個虛擬獨立桌面環境。
    但有個問題是 Ubuntu 13.10 之後 xrdp 不再支援系統預設的 Gnome 和 Unity 桌面,大部分使用者只好改用精緻度與功能較差的 xfac4 代替,網路上有配合 tightvncserver 繞道的解法(參考:xrdp完美实现Windows远程访问Ubuntu 16.04 - 法号阿兴 - 博客园),但我試不出來。最後決定改走遙控鍵盤滑鼠的路,用 RealVNC Viewer連接 Ubuntu 內建的桌面分享功能順利搞定。(參考:How to Remote Access to Ubuntu 16.04 from Windows - UbuntuHandbook)。
    從 Windows 使用 RealVNC Viewer 連接 Ubuntu 會遇到以下安全錯誤,Workaround 是使用 "sudo gsettings set org.gnome.Vino remote-access false" 關閉 Vino 加密:

  • Ubuntu Samba 伺服器設定 
    程式主要還是會用 Windows VS2017 / VSCode 開發,我習慣在 Ubuntu 執行跑 Samba Server 分享目錄, 在 Windows 寫好程式透過網路磁碟機部署檔案,再使用 Putty 或遠端桌面測試,這是我找到較順暢的整合方式。
    參考:在 Ubuntu 11.10 架設 Samba Server 及windows 7上的設定 @ 永˙宗˙看˙視˙界 -- 痞客邦 --
  • 在 Ubuntu 16.04 安裝 .NET SDK
    這部分較簡單,官方文章寫得很詳細。在 -NET Tutorial - Hello World in 10 minutes Tutorial Guid / Linux / Install the .NET SDK / 選好作業系統版本,依據說明步驟操作即可。

    wget -q https://packages.microsoft.com/config/ubuntu/16.04/packages-microsoft-prod.deb
    sudo dpkg -i packages-microsoft-prod.deb
    sudo apt-get install apt-transport-https
    sudo apt-get update
    sudo apt-get install dotnet-sdk-2.1
  • System.Data.SQLite 支援問題
    NuGet 上的 System.Data.Sqlite Package 執行時需要一個與平台相依的 Unmanaged Sqlite.Interop.dll 才能運作,而它只支援 net20/40/45/451/46,Sqlite.Interop.dll 分 x86/x64 只支援 Windows,故加入 .NET Core 專案編譯時將出現警告:'System.Data.SQLite.Core 1.0.108' was restored using '.NETFramework,Version=v4.6.1' instead of the project target framework '.NETCoreApp,Version=v2.1'. This package may not be fully compatible with your project.
    下場是程式在 Windows 測試正常,移到 Ubuntu 執行將出現錯誤: 
    Exception: System.DllNotFoundException: Unable to load shared library 'SQLite.Interop.dll' or one of its dependencies. In order to help diagnose loading problems, consider setting the LD_DEBUG environment variable: libSQLite.Interop.dll: cannot open shared object file: No such file or directory
       at System.Data.SQLite.UnsafeNativeMethods.sqlite3_config_none(SQLiteConfigOpsEnum op)
       at System.Data.SQLite.SQLite3.StaticIsInitialized()
       at System.Data.SQLite.SQLiteLog.Initialize()
       at System.Data.SQLite.SQLiteConnection..ctor(String connectionString, Boolean parseViaFramework)
       at SqliteDapper.Program.InitSQLiteDb() in /home/jeffrey/Labs/dotnet/SqliteDapper/Program.cs:line 51
       at SqliteDapper.Program.Main(String[] args) in /home/jeffrey/Labs/dotnet/SqliteDapper/Program.cs:line 17

    爬文找到編譯 Linux 專屬 libSQLite.Interop.so 取代 SQLite.Interop.dll 的做法:Using System.Data.SQLite under Linux and Mono - Wezeku,但似乎只適用於 Mono,幾經嘗試都不成功。
  • Microsoft.EntityFrameworkCore.Sqlite 突圍
    發現配合 EF 使用的 Microsoft.Data.Sqlite.Core NuGet Package 可在 Ubuntu 執行,其中也實作了 IDbConnection (SqliteConnection),讓我燃起一絲希望,但實測自己建立 Microsoft.Data.Sqlite.SqliteConnection 配合 Dapper 執行 .Query() / .Execute() 時都會彈出錯誤:
    You need to call SQLitePCL.raw.SetProvider().  If you are using a bundle package, this is done by calling SQLitePCL.Batteries.Init().
    靈機一動,先建立 EF DbContext(),再由 DbContext.GetDbConnection() 產生連線物件,搭配 Dapper .Query()/.Execute() 居然就成功了! 高興到差點從椅子上跳起來歡呼,總算沒枉費我跟它奮戰了大半個週末~

在 Ubuntu 跑 SQLite + Dapper 實測成功,算是攻上了小山頭,繼續推進。

Viewing all 2311 articles
Browse latest View live