最近寫到內容維護 UI,想留下資料修改軌跡。除了保留修改前修改後的完整內容,我還想出一份簡單的差異報告,指出哪幾行有更改,增加了哪幾行、哪幾行被刪除... 等,方便一眼看出變更重點;甚至不必保留舊版,用最新版加差異報告便能一路反推完整修改歷程,大量節省儲存空間。思考這個需求時,我想到 Git diff,它的比對報告就很接近我要的結果,但我的需求又更簡單一點,只要像這樣就好:

上圖最下方為 JSON 修改前後的增刪異動報告,第一欄為行號,如為舊版顯示於左,新版顯示在右方,接著是異動符號,< 表舊版內容,> 指新版內容,+ 表新增,- 表刪除,最後為文字內容。當有連續數行異動時,希望先一次顯示完舊版的多行資料,再顯示新版。(遇到 XML 元素拆成多行,這種排版比單方穿插可讀性高)

為實現這個功能,我找到一個老牌程式庫 - DiffPlex,用 NuGet 即可安裝:

DiffPlex 的程式說明及範例有點簡略,不過有原始碼在手,沒什麼可以難倒我們。Open Source 萬歲!

完整測試程式如下:

using DiffPlex;
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ConsoleApp1
{
    class Program
    {
        public class Post
        {
            public int Id { get; set; }
            public string Author { get; set; }
            public string Title { get; set; }
            public string Tags { get; set; }
            public string Content { get; set; }
            public string Remark { get; set; }
        }

        static void Main(string[] args)
        {
            var post = new Post()
            {
                Id = 1,
                Author = "Jeffrey",
                Title = "Hello, World!",
                Tags = "Fun,Test",
                Content = @"To be or not to be, that is the question.",
                Remark = ""
            };

            var jsonB4 = JsonConvert.SerializeObject(post, Formatting.Indented);

            var jo = JsonConvert.DeserializeObject<JObject>(jsonB4);
            jo["Id"] = 2;
            jo["Author"] = "Darkthread";
            jo.Property("Tags").Remove();
            jo.Add(new JProperty("Url", "http://blog.darkthread.net"));
            var jsonAft = JsonConvert.SerializeObject(jo, Formatting.Indented);

            var d = new Differ();
            var sbsDiffBuilder = new SideBySideDiffBuilder(new Differ());
            var diffResult = sbsDiffBuilder.BuildDiffModel(jsonB4, jsonAft);
            var diffLeft = new Dictionary<int, string>();
            var diffRight = new Dictionary<int, string>();
            var maxLineNo = 0;
            foreach (var line in diffResult.OldText.Lines)
            {
                if (line.Position == null || line.Type == ChangeType.Unchanged) continue;
                var symbol = "<";
                if (line.Type == ChangeType.Inserted) symbol = "+";
                else if (line.Type == ChangeType.Deleted) symbol = "-";
                diffLeft.Add(line.Position.Value, $"{symbol} {line.Text}");
                maxLineNo = Math.Max(line.Position.Value, maxLineNo);
            }

            foreach (var line in diffResult.NewText.Lines)
            {
                if (line.Position == null || line.Type == ChangeType.Unchanged) continue;
                var symbol = ">";
                if (line.Type == ChangeType.Inserted) symbol = "+";
                else if (line.Type == ChangeType.Deleted) symbol = "-";
                diffRight.Add(line.Position.Value, $"{symbol} {line.Text}");
                maxLineNo = Math.Max(line.Position.Value, maxLineNo);
            }
            var sb = new StringBuilder();
            List<string> buffLeft = new List<string>();
            List<string> buffRight = new List<string>();
            Action flushBuffer = () =>
            {
                buffLeft.ForEach(o => sb.AppendLine(o));
                buffLeft.Clear();
                buffRight.ForEach(o => sb.AppendLine(o));
                buffRight.Clear();
            };

            //以最大行數位數決定寬度
            var lineNoWidth = maxLineNo.ToString().Length + 1;
            var format = $"{{0,{lineNoWidth}}} {{1,{lineNoWidth}}}\t{{2}}";
            Func<int, bool, string, string> genLine = (lineNo, isLeft, text) =>
            {
                string n1 = isLeft ? lineNo.ToString() : " ";
                string n2 = isLeft ? " " : lineNo.ToString();
                return string.Format(format, n1, n2, text);
            };

            for (var i = 1; i<=maxLineNo; i++)
            {
                if (diffLeft.ContainsKey(i))
                    buffLeft.Add(genLine(i, true, diffLeft[i]));
                if (diffRight.ContainsKey(i))
                    buffRight.Add(genLine(i, false, diffRight[i]));

                if (diffLeft.ContainsKey(i) && diffRight.ContainsKey(i))
                    continue; //連續行數兩邊都有內容,暫不送出
                //否則送出內容
                flushBuffer();
            }
            flushBuffer();
            Console.WriteLine($"Orig\n====\n{jsonB4}");
            Console.WriteLine($"\nNew \n====\n{jsonAft}");
            Console.WriteLine("\nDiff\n====");
            Console.WriteLine(sb.ToString());
            //Console.ReadLine();
        }
    }
}

實測結果:

Orig
====
{
  "Id": 1,
  "Author": "Jeffrey",
  "Title": "Hello, World!",
  "Tags": "Fun,Test",
  "Content": "To be or not to be, that is the question.",
  "Remark": ""
}

New
====
{
  "Id": 2,
  "Author": "Darkthread",
  "Title": "Hello, World!",
  "Content": "To be or not to be, that is the question.",
  "Remark": "",
  "Url": "http://blog.darkthread.net"
}

Diff
====
 2      <   "Id": 1,
 3      <   "Author": "Jeffrey",
    2   >   "Id": 2,
    3   >   "Author": "Darkthread",
 5      -   "Tags": "Fun,Test",
    6   >   "Remark": "",
 7      <   "Remark": ""
    7   +   "Url": "http://blog.darkthread.net"

最後試一下 XML,以下範例展示是連續多行修改時,先印舊版 3-5 行再印新版 3-5 行的效果,可讀性較佳。

工具箱再添實用工具一件。

The example showing how to use DiffPlex to generate a 'git diff'-like report with C#.


Comments

# by Alan

黑大,小弟最近也有一樣的需求,感謝您無私的分享。

# by abc0922001

這不就是 git format-patch 輸出的結果嗎

# by ct

不曉得您需要的用途,不過使用git format-patch有類似的效果: git format-patch --no-prefix --no-binary <sha1>...<sha1> -o "d:\temp" ps. <sha1>為兩個commit的點,建議為相鄰的2個commit。

# by Jeffrey

to abc0922001, ct, 謝謝你們的回饋。 這個功能的點子來自git沒錯,一開始也有考慮直接引用 git.exe,後來基於緊密整合(外部 EXE vs .NET 程式庫)與當成練功題材的考量,決定自己寫。ct 補充的提示很棒,已筆記留待日後派上用場。

Post a comment