代码比较功能在很多版本管理工具中都存在,比如Git和TortoiseSVN,Compare Beyond中也有。这个功能可以很方便的帮助我们查看代码的变更。目前这些工具都是简单的基于文本差异的比较。然而在有些时候,我们需要基于语义的比较,比如在代码中,我只是调整了方法的前后顺序、添加了一些注释、删除了一些语句之间的空格,或是将一个大的类使用partial关键字,拆分到多个源文件中。在这种情况下,传统的基于简单文本进行代码比较的方式则失去了应有的作用。我们需要保证代码的逻辑和正确定不变即可,而不关心代码的书写格式,比如行号,空格,注释,格式化这些。
起因
起因是最开始时我们的一个.NET项目中有一个名为FormStk的窗体,逻辑非常复杂,FormStk.cs里面有复杂的UI界面交互和内部逻辑,这一块大代码导致后面的维护显得很困难。最开始的一个版本是,将新增加的功能使用partial关键字放在一个额外的FormStk.extra.cs中,它是一个按照功能划分的逻辑。
后来发现这样处理并没有从根本上解决代码难以维护的问题。
解决方案
对于这种规模庞大的类,有两个解决方案:
- 一种就是使用partial类进行物理分离,partial 允许将同一个类的代码写在多个 .cs 文件中。编译器在编译时会将它们合并。这是最快能让代码变得整洁的方法。
- 第二种就是逻辑分离,将代码中具有相同逻辑或功能的部分提取出来作为单独的类。
物理分离
物理分离是本文介绍的核心,比如对于我们项目中遇到的问题,可以将FormStk分离为以下几部分:
- FormStk.cs 保留,但只放构造函数,Load事件,窗体级别的全局定义的属性和私有字段。这样可以保持入口干净。
- FormStk.Designer.cs 这个是IDE自动维护的布局代码,不动。
- FormStk.Event.cs,手动创建,这里专门存放UI控件的事件处理代码比如 Button_Click、Grid_SelectionChanged等;以及订阅其它类的事件。
- FormStk.UI.cs,手动创建,用于存放刷新界面、重置控件状态、更新进度条等纯UI操作的方法。
- FormStk.Biz.cs,手动创建,用于存放私有的辅助方法,计算逻辑,数据处理函数。
按照以上逻辑就可以把FormStk.cs拆分为FormStk.Event.cs、FormStk.UI.cs和FormStk.Biz.cs。这只是代码的物理搬移,重新组织代码的时候,还可以根据用法将字段,属相,相同类型控件的代码排列放在一起,还可以使用 #region 这类编译器提供的功能将代码折叠方便查看。
下一步就是逻辑分离,可以将FormStk.Biz.cs中的一些功能相同的类提取到单独的类中,这不是本文重点,就不多介绍。
验证
在任何对代码进行修改之后,都要进行验证,虽然这种物理分离不容易引入错误,但是还是要小心为妙,还是有一定的几率会出错。这时就必须要有一个比较好的功能来对代码前后进行比较,查看代码是否有变化,但是要过滤掉代码的顺序不同,代码方法中的空格这类对代码逻辑不产生影响的变更和修改。
现有的工具,无论是Git、TortoiseSVN抑或是Compare Beyond都不支持针对语义的代码比较,它们都是基于文本的差异比较,只要是文本有差异,莫说是多了或少了空行它都会显示,如果是对方法的前后顺序进行了调整,那么就没法看了。但这也很好理解,这些都是通用的软件,它们不可能理解所有的编程语言的语法,所以要提供给与语义的比较是比较困难的。
我们使用的是.NET C#语言,市面上有一些所谓的针对C#语义比较工具,比如Code Compare ,我试用后还是不太明白用法,可能它根本不适合partial这种简单的物理搬移比较的场景。
好在C#中,基于语义分析有着现成的工具,所以实现起来不算困难。
基于Roslyn的语义比较
设计思路其实很简单直白:
- 第一步就是把多个不同的partial的文件简单的拼接成一个文件。
- 然后让Roslyn来解析,提取出文件中的类、字段、方法名(包含属性,事件)、方法内容等。
- 将上一步的分析结果,以“类_[method、.ctor、.dtor、property等]_返回类型_方法名_方法参数作为key,方法体作为value (这里的value可以是一个类,可以保存原始的文本和解析之后的语素的文本方便比较和显示)
- 将修改前后的多个文本分别按上述方法生成两个Dictionay<Key,CodeItem>,逐一的进行key比较,如果相等,则进一步比较CodeItem也就是方法的内容。
- 设计UI界面以显示左右代码的不同,包括,新增、缺失、差异等,并能进行左右的滚动条同步,如果有差异,能自动定位到第n个差异。
实现
因为这只是一个小工具,所以我使用的是WinForm App基于.NET 8.0,使用.NET Framework 4.8或之前的版本,在安装或运行 ”Microsoft.CodeAnalysis.CSharp“ 这个NuGet包时可能会报错,我这里使用的是5.0.0版本的”Microsoft.CodeAnalysis.CSharp“ 包。
它的核心代码在RoslynParser类中:
// 存储代码项的数据结构
public class CodeItem
{
public string Signature { get; set; } // Key
public string NormalizedCode { get; set; } // 用于比较 (无注释,无空白差异)
public string OriginalDisplayCode { get; set; } // 用于显示 (保留换行,便于阅读)
}
/// <summary>
/// Roslyn 解析器封装
/// </summary>
public static class RoslynParser
{
public static Dictionary<string, CodeItem> Parse(string sourceCode)
{
var result = new Dictionary<string, CodeItem>();
SyntaxTree tree = CSharpSyntaxTree.ParseText(sourceCode);
CompilationUnitSyntax root = tree.GetCompilationUnitRoot();
// 获取所有类
var classes = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
foreach (var cls in classes)
{
string className = cls.Identifier.Text;
// 1. 提取方法
foreach (var method in cls.Members.OfType<MethodDeclarationSyntax>())
{
string returnType = method.ReturnType.ToString();
string methodName = method.Identifier.Text;
string paramsList = string.Join(", ", method.ParameterList.Parameters.Select(p => p.ToString()));
// 生成Key: FormStk.[Method] void Init(int a)
string key = $"{className}.[Method] {returnType} {methodName}({paramsList})";
AddResult(result, key, (SyntaxNode)method.Body ?? method.ExpressionBody);
}
// 2. 提取构造函数 (区分 Static vs Instance)
foreach (var ctor in cls.Members.OfType<ConstructorDeclarationSyntax>())
{
string paramsList = string.Join(", ", ctor.ParameterList.Parameters.Select(p => p.ToString()));
// 检查是否包含 static 关键字
bool isStatic = ctor.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword));
string modifierPrefix = isStatic ? "static " : "";
// 生成Key: FormStk.[Ctor] static FormStk() VS FormStk.[Ctor] FormStk()
string key = $"{className}.[Ctor] {modifierPrefix}{className}({paramsList})";
AddResult(result, key, ctor.Body ?? (SyntaxNode)ctor.ExpressionBody);
}
// 3. 提取析构函数 (Destructor) ~ClassName()
foreach (var dtor in cls.Members.OfType<DestructorDeclarationSyntax>())
{
string key = $"{className}.[Dtor] ~{className}()";
AddResult(result, key, dtor.Body ?? (SyntaxNode)dtor.ExpressionBody);
}
// 4. 提取属性
foreach (var prop in cls.Members.OfType<PropertyDeclarationSyntax>())
{
string type = prop.Type.ToString();
string name = prop.Identifier.Text;
string key = $"{className}.[Property] {type} {name}";
AddResult(result, key, prop);
}
// 5. 提取字段
foreach (var field in cls.Members.OfType<FieldDeclarationSyntax>())
{
string type = field.Declaration.Type.ToString();
foreach (var variable in field.Declaration.Variables)
{
string name = variable.Identifier.Text;
string key = $"{className}.[Field] {type} {name}";
AddResult(result, key, variable.Initializer ?? (SyntaxNode)variable);
}
}
}
return result;
}
private static void AddResult(Dictionary<string, CodeItem> dict, string key, SyntaxNode node)
{
if (node == null) return; // 只有声明没有实现的接口/抽象方法暂不处理
//// NormalizedCode: 用于比较,移除所有 Trivia (空格/注释)
//string normalized = node.WithoutTrivia().ToFullString();
// --- 核心修复:使用 Token 级拼接来忽略所有内部空格 ---
// 之前的 WithoutTrivia() 只移除了最外层的空格。
// 现在的逻辑:将代码中所有有效的 Token(如 int, a, =, 1, ;)直接拼在一起。
// 结果: "int a = 1;" 和 "int a = 1;" 都会变成 "inta=1;"
string normalized = string.Join("", node.DescendantTokens().Select(t => t.Text));
// OriginalDisplayCode: 用于显示,Roslyn 自动格式化美观
string display = node.NormalizeWhitespace().ToFullString();
if (dict.ContainsKey(key))
{
int i = 2;
while (dict.ContainsKey(key + $"_#{i}")) i++;
key = key + $"_#{i}";
}
dict[key] = new CodeItem
{
Signature = key,
NormalizedCode = normalized,
OriginalDisplayCode = display
};
}
}
非常的简洁明了,使用 CSharpSyntaxTree.ParseText(sourceCode); 就可以直接得到 SyntexTree 语法树。接下来就是把语法树中我们希望比较的内容通过一定的规则放到字段中,供后续分析比较和展示了。
核心逻辑实现了之后就是界面设计,界面设计模仿通过的代码差异比较软件。完整代码如下:
public partial class Form1 : Form
{
// UI Controls
private TableLayoutPanel rootTable;
private SplitContainer mainVerticalSplit;
// Panels
private TableLayoutPanel tlpLeft, tlpRight;
private GroupBox grpLeft, grpRight;
private ListBox listLeftFiles, listRightFiles;
private RichTextBox rtbLeft, rtbRight;
private Button btnAddLeft, btnClearLeft, btnAddRight, btnClearRight;
// Core Controls
private Button btnCompare;
private StatusStrip statusStrip;
private ToolStripStatusLabel lblStatus;
// Navigation Controls
private ToolStripButton btnFirstDiff; // [新增] 回到第一个
private ToolStripButton btnPrevDiff;
private ToolStripStatusLabel lblDiffInfo;
private ToolStripButton btnNextDiff;
// Logic Data
private bool isScrolling = false;
private List<DiffLocation> diffList = new List<DiffLocation>();
private int currentDiffIndex = -1;
private struct DiffLocation
{
public int LeftIndex;
public int RightIndex;
}
public Form1()
{
this.Text = "C# 精确语义比对工具 (基于Roslyn工具分析)";
this.Size = new Size(1300, 850);
this.StartPosition = FormStartPosition.CenterScreen;
InitializeComponent();
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (mainVerticalSplit != null)
mainVerticalSplit.SplitterDistance = mainVerticalSplit.Width / 2;
}
private void InitializeComponent()
{
// 1. 状态栏 & 导航按钮
statusStrip = new StatusStrip();
lblStatus = new ToolStripStatusLabel { Text = "准备就绪。", Spring = true, TextAlign = ContentAlignment.MiddleLeft };
// [新增] 第一处差异按钮
btnFirstDiff = new ToolStripButton("⏮ 第一处差异", null, (s, e) => ShowDiff(0)) { Enabled = false };
btnPrevDiff = new ToolStripButton("▲ 上一个", null, (s, e) => NavigateDiff(-1)) { Enabled = false };
// 中间标签:点击可重新定位当前差异
lblDiffInfo = new ToolStripStatusLabel { Text = "0 / 0", Width = 80, TextAlign = ContentAlignment.MiddleCenter, IsLink = true, LinkBehavior = LinkBehavior.HoverUnderline, ToolTipText = "点击重新定位到当前差异" };
lblDiffInfo.Click += (s, e) => ShowDiff(currentDiffIndex);
btnNextDiff = new ToolStripButton("▼ 下一个", null, (s, e) => NavigateDiff(1)) { Enabled = false };
// 添加顺序:状态信息 | (弹簧) | 第一处 | 上一个 | 计数 | 下一个
statusStrip.Items.AddRange(new ToolStripItem[] { lblStatus, btnFirstDiff, btnPrevDiff, lblDiffInfo, btnNextDiff });
this.Controls.Add(statusStrip);
// 2. 根表格布局
rootTable = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(0)
};
rootTable.RowStyles.Add(new RowStyle(SizeType.Absolute, 50F));
rootTable.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
this.Controls.Add(rootTable);
rootTable.BringToFront();
// 3. 比较按钮
btnCompare = new Button
{
Text = "▼ 开始基于Roslyn的精确比对 (忽略格式/注释/位置/空白) ▼",
Dock = DockStyle.Fill,
Font = new Font("Segoe UI", 11, FontStyle.Bold),
BackColor = Color.LightSkyBlue,
Cursor = Cursors.Hand,
FlatStyle = FlatStyle.Flat,
Margin = new Padding(3, 3, 3, 5)
};
btnCompare.FlatAppearance.BorderSize = 1;
rootTable.Controls.Add(btnCompare, 0, 0);
// 4. 左右垂直分割
mainVerticalSplit = new SplitContainer
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
BorderStyle = BorderStyle.FixedSingle,
FixedPanel = FixedPanel.None
};
rootTable.Controls.Add(mainVerticalSplit, 0, 1);
// 构建左右面板
tlpLeft = CreateSidePanelLayout(out grpLeft, out listLeftFiles, out rtbLeft, out btnAddLeft, out btnClearLeft, "旧版本 (Left)");
tlpRight = CreateSidePanelLayout(out grpRight, out listRightFiles, out rtbRight, out btnAddRight, out btnClearRight, "新版本 (Right)");
mainVerticalSplit.Panel1.Controls.Add(tlpLeft);
mainVerticalSplit.Panel2.Controls.Add(tlpRight);
// --- 事件绑定 ---
// 垂直同步
rtbLeft.VScroll += (s, e) => SyncScroll(rtbLeft, rtbRight, SB_VERT);
rtbRight.VScroll += (s, e) => SyncScroll(rtbRight, rtbLeft, SB_VERT);
// 水平同步
rtbLeft.HScroll += (s, e) => SyncScroll(rtbLeft, rtbRight, SB_HORZ);
rtbRight.HScroll += (s, e) => SyncScroll(rtbRight, rtbLeft, SB_HORZ);
btnAddLeft.Click += (s, e) => AddFiles(listLeftFiles);
btnAddRight.Click += (s, e) => AddFiles(listRightFiles);
btnClearLeft.Click += (s, e) => listLeftFiles.Items.Clear();
btnClearRight.Click += (s, e) => listRightFiles.Items.Clear();
btnCompare.Click += (s, e) => PerformRoslynComparison();
}
private TableLayoutPanel CreateSidePanelLayout(
out GroupBox grp, out ListBox lst, out RichTextBox rtb,
out Button btnAdd, out Button btnClear, string title)
{
var tlp = new TableLayoutPanel
{
Dock = DockStyle.Fill,
ColumnCount = 1,
RowCount = 2,
Padding = new Padding(0)
};
tlp.RowStyles.Add(new RowStyle(SizeType.Absolute, 180F));
tlp.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
grp = new GroupBox { Text = title, Dock = DockStyle.Fill, Padding = new Padding(5) };
lst = new ListBox { Dock = DockStyle.Fill, IntegralHeight = false, BorderStyle = BorderStyle.FixedSingle };
var pnlTools = new Panel { Dock = DockStyle.Bottom, Height = 35, Padding = new Padding(2) };
btnAdd = new Button { Text = "添加文件", Dock = DockStyle.Left, Width = 80 };
btnClear = new Button { Text = "清空", Dock = DockStyle.Right, Width = 60 };
pnlTools.Controls.Add(btnAdd);
pnlTools.Controls.Add(btnClear);
grp.Controls.Add(lst);
grp.Controls.Add(pnlTools);
rtb = new RichTextBox
{
Dock = DockStyle.Fill,
Font = new Font("Consolas", 10),
WordWrap = false, // 允许水平滚动
BackColor = Color.WhiteSmoke,
ReadOnly = true,
BorderStyle = BorderStyle.None,
ScrollBars = RichTextBoxScrollBars.Both,
HideSelection = false
};
tlp.Controls.Add(grp, 0, 0);
tlp.Controls.Add(rtb, 0, 1);
return tlp;
}
// ================= 导航逻辑 =================
private void NavigateDiff(int direction)
{
if (diffList.Count == 0) return;
int newIndex = currentDiffIndex + direction;
if (newIndex < 0) newIndex = 0;
if (newIndex >= diffList.Count) newIndex = diffList.Count - 1;
ShowDiff(newIndex);
}
private void ShowDiff(int index)
{
if (index < 0 || index >= diffList.Count) return;
currentDiffIndex = index;
var loc = diffList[index];
// 更新 UI 状态
lblDiffInfo.Text = $"{index + 1} / {diffList.Count}";
// 只要有差异,First 就可用
btnFirstDiff.Enabled = diffList.Count > 0;
// Prev/Next 依赖于 index
btnPrevDiff.Enabled = index > 0;
btnNextDiff.Enabled = index < diffList.Count - 1;
isScrolling = true;
try
{
// 跳转左侧
rtbLeft.Select(loc.LeftIndex, 0);
rtbLeft.ScrollToCaret();
// 跳转右侧
rtbRight.Select(loc.RightIndex, 0);
rtbRight.ScrollToCaret();
// 将水平滚动条复位到最左侧 (0),方便阅读
SetScrollPos(rtbLeft.Handle, SB_HORZ, 0, true);
SendMessage(rtbLeft.Handle, WM_HSCROLL, (IntPtr)((0 << 16) | SB_THUMBPOSITION), IntPtr.Zero);
SetScrollPos(rtbRight.Handle, SB_HORZ, 0, true);
SendMessage(rtbRight.Handle, WM_HSCROLL, (IntPtr)((0 << 16) | SB_THUMBPOSITION), IntPtr.Zero);
}
finally
{
isScrolling = false;
}
}
// ================= Win32 同步滚动 =================
[DllImport("user32.dll")] private static extern int GetScrollPos(IntPtr hWnd, int nBar);
[DllImport("user32.dll")] private static extern int SetScrollPos(IntPtr hWnd, int nBar, int nPos, bool bRedraw);
[DllImport("user32.dll")] private static extern int SendMessage(IntPtr hWnd, int wMsg, IntPtr wParam, IntPtr lParam);
private const int SB_HORZ = 0;
private const int SB_VERT = 1;
private const int WM_HSCROLL = 0x114;
private const int WM_VSCROLL = 0x115;
private const int SB_THUMBPOSITION = 4;
private void SyncScroll(RichTextBox source, RichTextBox target, int orientation)
{
if (isScrolling) return;
try
{
isScrolling = true;
int pos = GetScrollPos(source.Handle, orientation);
SetScrollPos(target.Handle, orientation, pos, true);
int msg = (orientation == SB_HORZ) ? WM_HSCROLL : WM_VSCROLL;
SendMessage(target.Handle, msg, (IntPtr)((pos << 16) | SB_THUMBPOSITION), IntPtr.Zero);
}
finally
{
isScrolling = false;
}
}
// ================= 核心比对 =================
private void AddFiles(ListBox targetList)
{
using (OpenFileDialog ofd = new OpenFileDialog { Multiselect = true, Filter = "C# Files|*.cs|All Files|*.*" })
{
if (ofd.ShowDialog() == DialogResult.OK)
{
foreach (var file in ofd.FileNames)
{
if (!targetList.Items.Contains(file)) targetList.Items.Add(file);
}
}
}
}
private void PerformRoslynComparison()
{
try
{
lblStatus.Text = "正在解析...";
Application.DoEvents();
var leftDict = AggregateFiles(listLeftFiles);
var rightDict = AggregateFiles(listRightFiles);
var allKeys = leftDict.Keys.Union(rightDict.Keys).OrderBy(k => k).ToList();
rtbLeft.Clear();
rtbRight.Clear();
diffList.Clear(); // 清空旧的差异数据
int diffCount = 0;
foreach (var key in allKeys)
{
bool inLeft = leftDict.ContainsKey(key);
bool inRight = rightDict.ContainsKey(key);
string displayTitle = $"▶ {key}";
// --- 检查是否是差异点 ---
bool isDiff = false;
if (inLeft && inRight)
{
if (leftDict[key].NormalizedCode != rightDict[key].NormalizedCode) isDiff = true;
}
else
{
isDiff = true; // 只有一边有,也是差异
}
// --- 如果是差异,记录当前 RichTextBox 的起始位置 ---
if (isDiff)
{
diffList.Add(new DiffLocation
{
LeftIndex = rtbLeft.TextLength,
RightIndex = rtbRight.TextLength
});
diffCount++;
}
// --- 渲染内容 ---
if (inLeft && inRight)
{
var leftItem = leftDict[key];
var rightItem = rightDict[key];
if (!isDiff)
{
Color bg = Color.FromArgb(235, 255, 235);
AppendSection(rtbLeft, displayTitle, leftItem.OriginalDisplayCode, Color.DarkBlue, bg);
AppendSection(rtbRight, displayTitle, rightItem.OriginalDisplayCode, Color.DarkBlue, bg);
}
else
{
Color bg = Color.FromArgb(255, 255, 210);
AppendSection(rtbLeft, displayTitle + " (内容修改)", leftItem.OriginalDisplayCode, Color.Red, bg);
AppendSection(rtbRight, displayTitle + " (内容修改)", rightItem.OriginalDisplayCode, Color.Red, bg);
}
}
else if (inLeft && !inRight)
{
Color bg = Color.FromArgb(255, 235, 235);
AppendSection(rtbLeft, displayTitle, leftDict[key].OriginalDisplayCode, Color.DarkRed, bg);
AppendSection(rtbRight, displayTitle + " (缺失)", "", Color.Gray, bg);
}
else if (!inLeft && inRight)
{
Color bg = Color.FromArgb(235, 235, 255);
AppendSection(rtbLeft, displayTitle + " (缺失)", "", Color.Gray, bg);
AppendSection(rtbRight, displayTitle, rightDict[key].OriginalDisplayCode, Color.Blue, bg);
}
}
// --- 结果处理 ---
lblStatus.Text = diffCount == 0 ? "完美!所有逻辑语义完全一致。" : $"发现 {diffCount} 处逻辑差异。";
lblStatus.BackColor = diffCount == 0 ? Color.LightGreen : Color.LightPink;
// 自动跳转到第一个差异
if (diffCount > 0)
{
ShowDiff(0);
}
else
{
lblDiffInfo.Text = "0 / 0";
btnPrevDiff.Enabled = false;
btnNextDiff.Enabled = false;
// 回到顶部
rtbLeft.Select(0, 0); rtbLeft.ScrollToCaret();
rtbRight.Select(0, 0); rtbRight.ScrollToCaret();
}
}
catch (Exception ex)
{
MessageBox.Show("错误: " + ex.Message);
}
}
private Dictionary<string, CodeItem> AggregateFiles(ListBox listFiles)
{
var aggregated = new Dictionary<string, CodeItem>();
foreach (var item in listFiles.Items)
{
string path = item.ToString();
if (!File.Exists(path)) continue;
string code = File.ReadAllText(path);
var fileResult = RoslynParser.Parse(code);
foreach (var kvp in fileResult)
{
if (aggregated.ContainsKey(kvp.Key)) aggregated[kvp.Key] = kvp.Value;
else aggregated.Add(kvp.Key, kvp.Value);
}
}
return aggregated;
}
private void AppendSection(RichTextBox rtb, string title, string content, Color titleColor, Color bgColor)
{
rtb.SelectionStart = rtb.TextLength;
rtb.SelectionLength = 0;
rtb.SelectionColor = titleColor;
rtb.SelectionBackColor = bgColor;
rtb.SelectionFont = new Font(rtb.Font, FontStyle.Bold);
rtb.AppendText(title + "\r\n");
rtb.SelectionColor = Color.Black;
rtb.SelectionFont = new Font(rtb.Font, FontStyle.Regular);
if (!string.IsNullOrEmpty(content)) rtb.AppendText(content.TrimEnd() + "\r\n\r\n");
else rtb.AppendText("\r\n\r\n");
rtb.SelectionBackColor = rtb.BackColor;
}
}
运行界面如下:

上面是全部一致的情况,为了演示,我将FromStk.extra.cs中的某个方法名后缀加了一个2保存后,再次点击上方的比较按钮,可以看到差异。
