Trong bài trước, tôi đã giới thiệu WebPackageViewer, một công cụ đóng gói website tĩnh và trình xem website độc lập, tất cả được chứa trong một tệp thực thi duy nhất.
Một đặc điểm của công cụ này là cùng một EXE có thể hoạt động vừa như ứng dụng GUI để xem website, vừa như ứng dụng CLI để đóng gói hoặc giải nén. Tuy nhiên, việc hỗ trợ đồng thời hai kiểu giao diện trong một EXE khá khó và không có giải pháp hoàn toàn gọn gàng trên Windows. Bài viết này trình bày các lựa chọn để hiện thực mô hình hai chế độ theo cách sạch nhất có thể, cùng một số phương án tôi đã dùng trong các ứng dụng khác.
Các ứng dụng thương mại Markdown Monster, WebSurge và Documentation Monster đều có CLI để cung cấp các chức năng phổ biến cho script. Tuy vậy, với các sản phẩm này, tôi chọn hai EXE riêng: một cho GUI và một cho CLI. CLI sẽ điều khiển các chức năng của ứng dụng GUI. Đây là mô hình khác với công cụ WebPackageViewer đơn giản nhưng phù hợp hơn với các ứng dụng đầy đủ tính năng.
Mục lục
Các hướng tiếp cận
Có ba cách chính để xây dựng ứng dụng GUI và CLI:
- Tạo ứng dụng GUI có thêm khả năng tương tác với Console.
- Tạo ứng dụng Console rồi khởi chạy GUI từ đó.
- Tạo riêng ứng dụng CLI và GUI, đồng thời dùng chung phần chức năng.
Không cách nào hoàn hảo. Windows yêu cầu PE header của EXE chọn một trong hai subsystem: GUI hoặc Console. Vì vậy, mỗi hướng đều có những đánh đổi riêng.
1. Ứng dụng GUI có giao diện CLI
Đây là cách khó xử lý nhất, đặc biệt khi ban đầu bạn xây dựng ứng dụng GUI rồi mới muốn bổ sung CLI. Trong dự án .NET, loại ứng dụng được chỉ định bằng OutputType:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- WinExe: GUI, Exe: Console -->
<OutputType>WinExe</OutputType>
<TargetFramework>net472</TargetFramework>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
Với ứng dụng Console, Windows sẽ tạo Console mới nếu chưa có Console đang hoạt động. Nếu chương trình được gọi từ Terminal, nó sẽ gắn vào Console hiện tại và nhận các luồng Input, Output và Error.
Với ứng dụng GUI, khi chạy từ Explorer hoặc shell sẽ không có Console nào được tạo. Nếu chạy từ một Console hiện có, GUI và Console khởi chạy không tự động kết nối với nhau. Một ứng dụng GUI có thể gọi API native AttachConsole() để gắn vào Console hiện tại:
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(int dwProcessId);
protected override void OnStartup(StartupEventArgs e)
{
bool attached = AttachConsole(-1);
if (attached)
Console.Write("\nLaunching Web Viewer...");
// ...
if (attached)
FreeConsole();
}
Cách này hoạt động ở mức cơ bản, nhưng có vấn đề lớn: ứng dụng GUI trả quyền điều khiển về Console gần như ngay lập tức trong khi vẫn tiếp tục chạy ở nền. Vì vậy, prompt mới có thể xuất hiện giữa phần output của ứng dụng. Khi gọi FreeConsole(), prompt cũng có thể bị mất vị trí và Console trông như bị treo cho đến khi người dùng nhấn phím.
Để giảm lỗi hiển thị, có thể giải phóng Console bằng một helper:
[DllImport("user32.dll")]
static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, nuint dwExtraInfo);
const byte VK_RETURN = 0x0D;
static void ReleaseConsolePrompt()
{
Console.WriteLine();
FreeConsole();
keybd_event(VK_RETURN, 0, 0, 0);
keybd_event(VK_RETURN, 0, 0x0002, 0);
}
Đoạn code này tạo thêm một dòng mới, ngắt kết nối Console rồi mô phỏng phím Enter để prompt xuất hiện ở cuối.
Có thể chấp nhận cách này không?
Với công cụ nội bộ có output ngắn, cách làm trên có thể đủ tốt. Nhưng với công cụ thương mại hoặc tác vụ chạy lâu, kết quả trông khá lộn xộn. Vị trí prompt có thể thay đổi tùy lần chạy: đôi khi prompt chen vào output, đôi khi lại không.
Nếu CLI chủ yếu được gọi bởi automation và người dùng không trực tiếp quan sát Console, hạn chế này có thể chấp nhận được. Đây là cách dễ nhất để bổ sung CLI cho một ứng dụng GUI và trong một số trường hợp, nó là lựa chọn “đủ tốt”.
Một thủ thuật nhỏ
Có thể làm tình hình dễ nhìn hơn bằng cách chờ một khoảng rất ngắn để Console hiển thị prompt mới trước khi ghi output:
protected override void OnStartup(StartupEventArgs e)
{
IsConsoleApp = AttachConsole(-1);
if (IsConsoleApp)
{
System.Threading.Thread.Sleep(20);
Console.WriteLine();
}
}
Khoảng dừng ngắn này buộc prompt cũ hoàn tất trước, sau đó output của ứng dụng mới được ghi. Prompt thừa vẫn tồn tại, nhưng nằm ở vị trí dễ đoán hơn và không chen vào giữa nội dung.
Khi nào nên dùng?
Hướng này phù hợp khi GUI là chức năng chính, CLI chỉ là phần phụ, và CLI phần lớn được gọi tự động. Với WebPackageViewer, các điều kiện đó đều đúng:
- Bắt buộc phải có một EXE duy nhất.
- GUI là giao diện chính mà người dùng nhìn thấy.
- CLI chủ yếu phục vụ automation.
- Output của CLI đủ dùng sau khi áp dụng các thủ thuật trên.
2. Ứng dụng Console khởi chạy GUI
Hướng ngược lại cũng khả thi: xây dựng ứng dụng với subsystem Console, rồi khởi chạy giao diện GUI từ ứng dụng đó. Về mặt triển khai, khác biệt giữa ứng dụng GUI và Console không quá lớn. Với WPF, chỉ cần thay đổi vài dòng.
Trước hết, đặt OutputType thành Exe:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Exe: Console, WinExe: GUI -->
<OutputType>Exe</OutputType>
<TargetFramework>net472</TargetFramework>
<UseWPF>true</UseWPF>
</PropertyGroup>
</Project>
Sau đó, thêm phương thức Main() để khởi tạo ứng dụng WPF:
using System;
using WebPackageViewer;
class Program
{
[STAThread]
static int Main(string[] args)
{
var app = new App();
app.InitializeComponent();
app.Run();
return 0;
}
}
Có thể chỉ định lớp khởi động trong file dự án:
<PropertyGroup>
<StartupObject>Program</StartupObject>
</PropertyGroup>
Cách này cũng hữu ích cho các ứng dụng GUI thông thường, vì Main() là nơi thuận tiện để thực hiện kiểm tra khởi động, hiển thị màn hình chờ hoặc xử lý tham số trước khi tạo cửa sổ chính.
Ưu điểm lớn là không cần gọi AttachConsole() hay FreeConsole(). Các luồng Console hoạt động đúng như mong đợi.
Nhược điểm
Ứng dụng được biên dịch như Console luôn tạo một cửa sổ Console. Không có cách hoàn hảo để ẩn cửa sổ này ngay từ đầu. Có thể ẩn nó rất sớm:
if (!StartedFromConsole())
{
ShowWindow(GetConsoleWindow(), 0);
}
Tuy nhiên, người dùng vẫn có thể thấy cửa sổ Console nhấp nháy hoặc biến mất theo hiệu ứng chậm. Việc xác định Console được tạo mới bởi ứng dụng hay đã tồn tại từ trước cũng không đơn giản, vì ứng dụng Console luôn có một Console gắn kèm.
Do đó, nếu ứng dụng chủ yếu là GUI, cửa sổ Console bật lên trong chốc lát có thể tạo cảm giác thiếu chuyên nghiệp. Nếu ứng dụng chủ yếu là CLI và GUI chỉ đóng vai trò phụ, nhược điểm này thường không đáng kể.
Khi nào nên dùng?
Hãy chọn hướng này khi:
- CLI là giao diện chính.
- GUI chỉ là phần bổ sung.
- Bạn có thể chấp nhận cửa sổ Console xuất hiện trong thời gian ngắn.
3. Tách riêng ứng dụng CLI và GUI
Đây là phương án sạch nhất: tạo hai ứng dụng hoàn toàn riêng biệt, mỗi ứng dụng có subsystem phù hợp với mục đích của nó.
Ứng dụng GUI có thể là WPF hoặc WinForms. Ứng dụng CLI chỉ chịu trách nhiệm phân tích tham số và gọi các chức năng nghiệp vụ dùng chung. Hai dự án không cần phải chứa cùng toàn bộ logic. CLI có thể tham chiếu đến project GUI hoặc, tốt hơn, đến một thư viện nghiệp vụ riêng.
Ví dụ với Markdown Monster:
MarkdownMonster: ứng dụng GUI WPF.mmcli: ứng dụng Console.
mmcli tham chiếu đến các assembly của Markdown Monster và có thể gọi những chức năng như cài đặt, tạo output HTML hoặc PDF, khởi động và dừng web server nội bộ, cùng nhiều thao tác hệ thống khác.
CLI có thể rất gọn
Project CLI thường chỉ chứa logic phân tích lệnh và lớp gọi các chức năng nghiệp vụ. Khi build, chỉ cần sao chép các file cần thiết của CLI vào thư mục ứng dụng GUI:
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<Exec Command="copy $(TargetDir)mmcli.exe $(SolutionDir)MarkdownMonster\mmcli.exe" />
<Exec Command="copy $(TargetDir)mmcli.dll $(SolutionDir)MarkdownMonster\mmcli.dll" />
<Exec Command="copy $(TargetDir)mmcli.runtimeconfig.json $(SolutionDir)MarkdownMonster\mmcli.runtimeconfig.json" />
<Exec Command="copy $(TargetDir)mmcli.deps.json $(SolutionDir)MarkdownMonster\mmcli.deps.json" />
</Target>
Vì hai project dùng chung dependency, output build của CLI có thể lớn, nhưng footprint thực tế khi phân phối chỉ cần các file CLI riêng. Các thư viện đã có sẵn trong thư mục của ứng dụng GUI nên CLI có thể sử dụng chúng mà không cần sao chép toàn bộ.
Khi nào nên dùng?
Nếu có thể chấp nhận nhiều EXE, đây là giải pháp rõ ràng và đáng tin cậy nhất. Mỗi ứng dụng chạy trong đúng môi trường của mình, không cần hack hay workaround để xử lý Console.
Nhược điểm duy nhất là cần thêm một project, đồng thời phải bảo đảm EXE, DLL và các file cấu hình runtime được đặt đúng vị trí.
Tóm tắt
Ứng dụng thật sự hỗ trợ đồng thời GUI và CLI không quá phổ biến. Phần lớn ứng dụng chỉ chọn một giao diện. Các ứng dụng GUI có tham số dòng lệnh thường hiển thị thông tin bằng cửa sổ hoặc hộp thoại thay vì ghi ra Console.
Tuy vậy, với những trường hợp như WebPackageViewer, việc có cả giao diện trực quan và CLI để automation rất hữu ích.
So sánh ba lựa chọn
- GUI có tương tác Console
- Phù hợp khi GUI là giao diện chính.
- Dễ bổ sung vào ứng dụng hiện có.
- Cần dùng
AttachConsole()vàFreeConsole(). - CLI có hiện tượng prompt lộn xộn, phù hợp hơn với các tác vụ nhanh hoặc automation.
- Console khởi chạy GUI
- Phù hợp khi CLI là trung tâm và GUI là phần bổ sung.
- Cần có
Program.csđể khởi chạy GUI. - Console hoạt động đúng chuẩn.
- GUI có thể làm cửa sổ Terminal nhấp nháy khi khởi động.
- Tách riêng GUI và CLI
- Có hai EXE riêng.
- Tách biệt rõ trách nhiệm.
- Không cần định tuyến giữa hai chế độ.
- CLI có thể dùng chung logic từ ứng dụng GUI hoặc thư viện nghiệp vụ.
- Không có lỗi hiển thị đặc thù của GUI hay CLI.
Với WebPackageViewer, tôi chọn ứng dụng GUI có hỗ trợ CLI vì yêu cầu bắt buộc là một file duy nhất, GUI là chức năng chính và CLI chủ yếu phục vụ automation.
Với các sản phẩm lớn hơn, tôi thường chọn hai project riêng. Đây là cách sạch hơn, đặc biệt khi ứng dụng đã có nhiều tham số khởi động, cơ chế giới hạn một instance hoặc các quy tắc CLI phức tạp. Tách riêng cú pháp CLI giúp tránh nhập nhằng và dễ bảo trì hơn.
Nếu không bị ràng buộc bởi yêu cầu một EXE duy nhất, hai project riêng gần như luôn là lựa chọn tốt nhất. Bạn vẫn có toàn bộ chức năng của ứng dụng chính trong CLI, đồng thời giữ được ranh giới rõ ràng giữa hai giao diện.
Còn ứng dụng Console khởi chạy GUI sẽ rất hấp dẫn nếu Windows cho phép ẩn Console ngay từ đầu. Đáng tiếc là hiện chưa có cách hoàn hảo để làm việc đó; giải pháp phổ biến nhất vẫn là ẩn cửa sổ ngay sau khi khởi động và chấp nhận một thoáng nhấp nháy.
Cuối cùng, WebPackageViewer là một thử nghiệm thú vị. Tôi đã thử nhiều cách để làm cho mô hình hai giao diện hoạt động “hoàn hảo”. Kết quả là có vài phương án đủ tốt, nhưng giải pháp gần như hoàn hảo vẫn là tách thành hai EXE.
Đôi khi “đủ tốt” cũng là đủ.



