Trong bộ công cụ devtools chạy trên web của mình có một tool quản lý kết nối PostgreSQL: người dùng dán connection string vào, mình phân tích ra thành từng trường (host, port, database, user, password), hoặc điền các trường rồi sinh ra connection string để mang sang app khác.

Nghe thì đơn giản, nhưng có một vấn đề dai dẳng: connection string của Npgsql có nhiều phiên bản, mỗi phiên bản lại lưu theo một kiểu khác nhau — tự chế parser thì không thể “care” hết được. Bài viết này kể cách mình giải quyết bằng cách đưa chính thư viện Npgsql chạy trên web qua WebAssembly.

Vấn đề: connection string đa dạng, tự chế parser không cover hết

Một connection string PostgreSQL có thể xuất hiện dưới nhiều dạng, tùy nơi người dùng lấy ra:

  • DSN kiểu libpq (dùng trong các tool như DBeaver, psql): host=localhost port=5432 user=manh password='123' dbname=mydb sslmode=disable
  • URI: postgresql://manh:123@localhost:5432/mydb?sslmode=disable
  • Npgsql (.NET): Host=localhost;Port=5432;Database=mydb;Username=manh;Password=123 — và qua các phiên bản Npgsql, key name đổi liên tục: User ID hay Username, Server hay Host, SSL Mode hay SslMode, …

Với một parser tự viết bằng JS, muốn cover được hết thì phải tự liệt kê mọi key của mọi phiên bản:

TDDatabaseConnectionMixin.js javascript
 1parseConnectionStringToFields(connStr) {
 2  let fields = { host: "", port: "5432", database: "", username: "", password: "", sslmode: "disable" };
 3  if (!connStr) return fields;
 4
 5  if (connStr.startsWith("postgresql://") || connStr.startsWith("postgres://")) {
 6    const url = new URL(connStr);
 7    fields.host = url.hostname || "localhost";
 8    fields.port = url.port || "5432";
 9    fields.database = url.pathname?.replace(/^\//, "") || "";
10    fields.username = decodeURIComponent(url.username || "");
11    fields.password = decodeURIComponent(url.password || "");
12    fields.sslmode = url.searchParams.get("sslmode") || "disable";
13  } else {
14    const parts = connStr.match(/(?:[^\s']+|'[^']*')+/g) || [];
15    parts.forEach((p) => {
16      const eqIdx = p.indexOf("=");
17      if (eqIdx > -1) {
18        const key = p.substring(0, eqIdx).trim();
19        let val = p.substring(eqIdx + 1).trim();
20        if (val.startsWith("'") && val.endsWith("'")) {
21          val = val.substring(1, val.length - 1).replace(/\\'/g, "'");
22        }
23        if (key === "host") fields.host = val;
24        if (key === "port") fields.port = val;
25        if (key === "dbname") fields.database = val;
26        if (key === "user") fields.username = val;
27        if (key === "password") fields.password = val;
28        if (key === "sslmode") fields.sslmode = val;
29      }
30    });
31  }
32  return fields;
33}

Vấn đề của hướng này:

  • Mỗi version Npgsql thêm/sửa key là phải sửa parser theo.
  • Các trường hợp ngoại lệ như escape password, SSL Mode, key cũ User ID/Server, giá trị dạng boolean — đều phải tự xử lý tay, dễ sót.
  • Càng về sau, parser tự chế càng xa so với thư viện gốc — vì mình không phải là người viết Npgsql, làm sao biết hết quy luật của nó.

Ý tưởng: dùng chính Npgsql để đọc connection string của Npgsql

Nhận ra điểm mấu chốt: connection string Npgsql do NpgsqlConnectionStringBuilder sinh ra, thì chỉ có NpgsqlConnectionStringBuilder mới hiểu trọn vẹn được nó. Nó là “nguồn sự thật” duy nhất — nó biết key nào hợp lệ, chuẩn hóa key cũ thành key mới, xử lý escape, SSL Mode, v.v.

Vậy tại sao không đưa chính Npgsql lên web? .NET giờ đã hỗ trợ biên dịch ra WebAssembly (browser-wasm) — chỉ cần viết một wrapper nhỏ, build ra WASM, rồi gọi từ JS như một module bình thường.

Triển khai: wrapper .NET nhỏ, build ra WASM

Mình tạo một project console .NET 10 với RuntimeIdentifier=browser-wasm, tham chiếu Npgsql NuGet package thật:

Tools.NetWrapper.csproj xml
 1<Project Sdk="Microsoft.NET.Sdk">
 2  <PropertyGroup>
 3    <OutputType>Exe</OutputType>
 4    <TargetFramework>net10.0</TargetFramework>
 5    <RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
 6    <WasmOptLevel>2</WasmOptLevel>
 7    <TrimMode>full</TrimMode>
 8    <PublishTrimmed>true</PublishTrimmed>
 9    <InvariantGlobalization>true</InvariantGlobalization>
10  </PropertyGroup>
11  <ItemGroup>
12    <PackageReference Include="Npgsql" Version="10.0.3" />
13  </ItemGroup>
14</Project>

Wrapper chỉ gồm 2 hàm, đánh dấu [JSExport] để gọi được từ JavaScript:

  • ParseNpgSQLConnection — nhận connection string, dùng NpgsqlConnectionStringBuilder parse thành object JSON trả về UI.
  • StringifyNpgSQLConnection — nhận object JSON các trường, dùng builder dựng lại connection string chuẩn.
TDToolDotNetWrapper.cs csharp
 1using System.Runtime.InteropServices.JavaScript;
 2using System.Text.Json;
 3using System.Text.Json.Serialization;
 4using Npgsql;
 5
 6namespace TDTools
 7{
 8    [JsonSourceGenerationOptions(WriteIndented = true)]
 9    [JsonSerializable(typeof(TDPosgreSQLCnonectionString))]
10    public partial class TDToolPosgreSQlContextString : JsonSerializerContext
11    {
12    }
13
14    public class TDPosgreSQLCnonectionString
15    {
16        public string? user_name { get; set; }
17        public string? password { get; set; }
18        public string? host { get; set; }
19        public int port { get; set; }
20        public string? database_name { get; set; }
21    }
22
23    public static partial class TDToolDotNetWrapper
24    {
25        /// <summary>
26        /// Đọc connection string của Npgsql rồi parse về object cho UI.
27        /// Mỗi phiên bản Npgsql lưu connection string một kiểu khác nhau,
28        /// nên để chính NpgsqlConnectionStringBuilder lo việc này.
29        /// </summary>
30        [JSExport]
31        public static string ParseNpgSQLConnection(string source)
32        {
33            if (string.IsNullOrEmpty(source))
34            {
35                throw new ArgumentNullException(nameof(source));
36            }
37
38            NpgsqlConnectionStringBuilder npgParsedConnect = new NpgsqlConnectionStringBuilder(source);
39            TDPosgreSQLCnonectionString connectionConvert = new TDPosgreSQLCnonectionString()
40            {
41                user_name = npgParsedConnect.Username,
42                password = npgParsedConnect.Password,
43                host = npgParsedConnect.Host,
44                port = npgParsedConnect.Port,
45                database_name = npgParsedConnect.Database
46            };
47
48            return JsonSerializer.Serialize(
49                connectionConvert,
50                TDToolPosgreSQlContextString.Default.TDPosgreSQLCnonectionString);
51        }
52
53        /// <summary>
54        /// Nhận các trường từ UI, dùng NpgsqlConnectionStringBuilder dựng lại
55        /// connection string chuẩn.
56        /// </summary>
57        [JSExport]
58        public static string StringifyNpgSQLConnection(string source)
59        {
60            if (string.IsNullOrEmpty(source))
61            {
62                throw new ArgumentNullException(nameof(source));
63            }
64
65            TDPosgreSQLCnonectionString? parseConnect =
66                JsonSerializer.Deserialize<TDPosgreSQLCnonectionString>(
67                    source,
68                    TDToolPosgreSQlContextString.Default.TDPosgreSQLCnonectionString);
69
70            NpgsqlConnectionStringBuilder npgParsedConnect = new NpgsqlConnectionStringBuilder()
71            {
72                Username = parseConnect.user_name,
73                Password = parseConnect.password,
74                Host = parseConnect.host,
75                Port = parseConnect.port,
76                Database = parseConnect.database_name
77            };
78
79            return npgParsedConnect.ConnectionString;
80        }
81    }
82}

Lưu ý nhỏ nhưng quan trọng: project build với PublishTrimmed + TrimMode=full để ra file WASM gọn. Khi trim, reflection bị chặn nên không thể dùng JsonSerializer.Serialize(obj) kiểu reflection thông thường — mình dùng Source Generator (JsonSerializerContext + [JsonSerializable]) để sinh code tuần tự hóa tại compile time.

Build xong bằng lệnh dotnet publish, script tự copy thư mục _framework sang src_wasm/pkg/dotnet:

build.sh bash
 1#!/bin/bash
 2set -e
 3SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 4OUTPUT_PKG_DIR="$(dirname "$SCRIPT_DIR")/pkg/dotnet"
 5
 6dotnet publish Tools.NetWrapper.csproj -c Release
 7
 8BUNDLE_FRAMEWORK_DIR="$SCRIPT_DIR/bin/Release/net10.0/browser-wasm/AppBundle/_framework"
 9
10rm -rf "$OUTPUT_PKG_DIR"
11mkdir -p "$OUTPUT_PKG_DIR"
12cp -R "$BUNDLE_FRAMEWORK_DIR/" "$OUTPUT_PKG_DIR"

Đưa WASM lên frontend: Vite + Vue

1. Alias và copy file WASM vào output

Cấu hình vite.config.js: alias @wasm trỏ tới src_wasm, và dùng viteStaticCopy để mỗi lần build web, toàn bộ src_wasm/pkg/dotnet/* được copy ra assets-wasm-<version>/ — version nằm trong tên thư mục để cache busting, tránh trình duyệt giữ file WASM cũ:

vite.config.js javascript
 1const APP_VERSION = packageJson.version;
 2
 3export default defineConfig({
 4  plugins: [
 5    vue(),
 6    viteStaticCopy({
 7      targets: [
 8        {
 9          src: "src_wasm/pkg/dotnet/*",
10          dest: `assets-wasm-${APP_VERSION}`,
11          rename: { stripBase: true },
12        },
13      ],
14    }),
15  ],
16  resolve: {
17    alias: {
18      "@": fileURLToPath(new URL("./src", import.meta.url)),
19      "@wasm": fileURLToPath(new URL("./src_wasm", import.meta.url)),
20    },
21  },
22});

2. Mixin khởi tạo .NET WASM runtime

Tất cả component cần dùng C# đều dùng chung một mixin. Nó tải runtime, nạp Tools.NetWrapper.dll, lấy ra 2 hàm export đã khai báo [JSExport]:

TDDotNetWasmMixin.js javascript
 1async initDotNetWasm() {
 2  if (this.dotnetInitialized) return;
 3
 4  let dotnetModule;
 5  if (import.meta.env.DEV) {
 6    // DEV: import qua alias, Vite tự resolve
 7    const { dotnet } = await import("@wasm/pkg/dotnet/dotnet.js");
 8    dotnetModule = dotnet;
 9  } else {
10    // PROD: import từ file đã copy ra assets-wasm-<version>
11    let APP_VERSION = tdUtility.getAppVersion();
12    const prodPath = `/assets-wasm-${APP_VERSION}/dotnet.js`;
13    const { dotnet } = await import(/* @vite-ignore */ prodPath);
14    dotnetModule = dotnet;
15  }
16
17  const { getAssemblyExports } = await dotnetModule
18    .withDiagnosticTracing(false)
19    .create();
20
21  const exports = await getAssemblyExports("Tools.NetWrapper.dll");
22  this.dotnetExports = exports.TDTools.TDToolDotNetWrapper;
23  this.dotnetInitialized = true;
24}

Mình cố ý tách DEV và PROD ra hai đường import khác nhau, vì trong môi trường build production Vite sẽ quét và bundle mọi dynamic import — mà file WASM runtime thì không nên bundle. Dùng chuỗi @vite-ignore khiến Vite bỏ qua, để file dotnet.js được tải trực tiếp từ đường dẫn đã copy.

3. Gọi hàm từ component

Giờ mọi nơi trong app chỉ cần gọi đúng 2 hàm đó. Ví dụ khi người dùng dán connection string từ app .NET khác vào:

TDPostgreSQLConnectionPopup.vue javascript
 1const jsonResult = this.dotnetExports.ParseNpgSQLConnection(
 2  this.connectionStringFromAnotherApp.trim(),
 3);
 4const parsedObj = JSON.parse(jsonResult);
 5
 6this.connFields.host = parsedObj.host || "";
 7this.connFields.port = parsedObj.port ? String(parsedObj.port) : "";
 8this.connFields.username = parsedObj.user_name || "";
 9this.connFields.password = parsedObj.password || "";
10this.connFields.database = parsedObj.database_name || "";

Chiều ngược lại — sinh connection string để người dùng copy sang app khác (chẳng hạn app Go) — là hàm StringifyNpgSQLConnection, nó trả về đúng chuỗi do NpgsqlConnectionStringBuilder tạo ra:

TDPostgreSQLQuery.vue javascript
 1const jsonStr = JSON.stringify({
 2  host: parsed.host,
 3  port: parseInt(parsed.port) || 5432,
 4  user_name: parsed.username,
 5  password: parsed.password,
 6  database_name: parsed.database,
 7});
 8const npgSqlConnStr =
 9  me.dotnetExports.StringifyNpgSQLConnection(jsonStr);
10me.$tdUtility.copyToClipboard(npgSqlConnStr);

Vì sao cách này “care” được hết mọi phiên bản

Bí mật nằm ở chỗ mình không hề tự viết logic parse — logic đó là của chính Npgsql, được biên dịch nguyên vẹn thành WASM:

  • NpgsqlConnectionStringBuilder biết mọi key hợp lệ của mọi phiên bản: User ID/Username, Server/Host, SSL Mode/SslMode, … — nó tự chuẩn hóa.
  • Mọi trường hợp khó như escape password, URI form, boolean, đều do builder xử lý — mình không cần quan tâm.
  • Npgsql phát hành phiên bản mới có thêm key mới → chỉ cần nâng version NuGet, rebuild WASM, không phải sửa một dòng parser.
  • Cùng một code chuẩn cho cả hai chiều: parse (dán vào → ra fields) và stringify (fields → ra connection string) — không bao giờ lệch nhau.

Nếu WASM chưa kịp khởi tạo, component vẫn có một fallback bằng parser JS cũ — chỉ đủ cover 2 format cơ bản (URI và DSN), còn khi WASM sẵn sàng thì giao hẳn cho Npgsql.

Tổng kết

  • Đừng tự viết lại logic parse dữ liệu mà một thư viện đã sinh ra — hãy dùng chính thư viện đó.
  • .NET 10 hỗ trợ build browser-wasm trực tiếp: project nhỏ, [JSExport] cho từng hàm, publish ra WASM.
  • Nhớ dùng Source Generator cho JSON khi build trimmed, kẻo gặp lỗi reflection lúc runtime.
  • Tách riêng đường import DEV/PROD cho file WASM, copy ra thư mục có version để tránh cache cũ.

Với cách này, tool web của mình đọc và tạo connection string PostgreSQL đúng chuẩn Npgsql của mọi phiên bản, mà không phải “tự chế” và đuổi theo từng bản release của thư viện.