🐛 fix(clickhouse): 修复协议选择与连接错误提示

- 支持 ClickHouse 手动 HTTP/Native 协议优先级,避免 URI scheme 覆盖用户选择
- Auto 模式识别 Native/HTTP 协议误配错误并自动尝试备用协议
- 净化连接失败中的二进制乱码,补充测试连接参数校验和排查日志
- 前端表单增加 ClickHouse 协议选择并同步类型、缓存 key 与持久化兼容
Refs #425
This commit is contained in:
Syngnat
2026-04-29 17:16:37 +08:00
parent b1ef52f62e
commit 0c1586d7a4
13 changed files with 672 additions and 29 deletions

View File

@@ -95,6 +95,7 @@ type ChoiceCardOption = {
label: string;
description?: string;
};
type ClickHouseProtocolChoice = "auto" | "http" | "native";
const MAX_URI_LENGTH = 4096;
const MAX_URI_HOSTS = 32;
const MAX_TIMEOUT_SECONDS = 3600;
@@ -102,6 +103,25 @@ const CONNECTION_MODAL_WIDTH = 960;
const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = "rgba(255, 255, 255, 0.16)";
const STEP1_SIDEBAR_DIVIDER_LIGHT = "rgba(0, 0, 0, 0.08)";
const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{
value: ClickHouseProtocolChoice;
label: string;
}> = [
{ value: "auto", label: "自动" },
{ value: "http", label: "HTTP" },
{ value: "native", label: "Native" },
];
const normalizeClickHouseProtocolValue = (
value: unknown,
): ClickHouseProtocolChoice => {
const text = String(value || "")
.trim()
.toLowerCase();
if (text === "http" || text === "https") return "http";
if (text === "native" || text === "tcp") return "native";
return "auto";
};
type ConnectionSecretKey =
| "primaryPassword"
| "sshPassword"
@@ -848,9 +868,7 @@ const ConnectionModal: React.FC<{
}
};
const resolveDriverUnavailableReason = async (
type: string,
): Promise<string> => {
const resolveDriverUnavailableReason = async (type: string): Promise<string> => {
const normalized = normalizeDriverType(type);
if (!normalized || normalized === "custom") {
return "";
@@ -1000,6 +1018,13 @@ const ConnectionModal: React.FC<{
}
};
const normalizeUriBool = (raw: unknown) => {
const text = String(raw ?? "")
.trim()
.toLowerCase();
return text === "1" || text === "true" || text === "yes" || text === "on";
};
const normalizeFileDbPath = (rawPath: string): string => {
let pathText = String(rawPath || "").trim();
if (!pathText) {
@@ -1117,6 +1142,44 @@ const ConnectionModal: React.FC<{
};
};
const parseClickHouseHTTPUriToValues = (
uriText: string,
fallbackPort?: number,
): Record<string, any> | null => {
const trimmed = String(uriText || "").trim();
const lower = trimmed.toLowerCase();
const isHttps = lower.startsWith("https://");
const isHttp = lower.startsWith("http://");
if (!isHttp && !isHttps) {
return null;
}
const defaultPort =
Number.isFinite(Number(fallbackPort)) && Number(fallbackPort) > 0
? Number(fallbackPort)
: isHttps
? 8443
: 8123;
const parsed = parseSingleHostUri(
trimmed,
[isHttps ? "https" : "http"],
defaultPort,
);
if (!parsed) {
return null;
}
const skipVerify = normalizeUriBool(parsed.params.get("skip_verify"));
return {
host: parsed.host,
port: parsed.port,
user: parsed.username,
password: parsed.password,
database: parsed.database || "",
clickHouseProtocol: "http",
useSSL: isHttps,
sslMode: isHttps ? (skipVerify ? "skip-verify" : "required") : "disable",
};
};
const parseUriToValues = (
uriText: string,
type: string,
@@ -1337,6 +1400,13 @@ const ConnectionModal: React.FC<{
};
}
if (type === "clickhouse") {
const httpValues = parseClickHouseHTTPUriToValues(trimmedUri);
if (httpValues) {
return httpValues;
}
}
const singleHostSchemes = singleHostUriSchemesByType[type];
if (singleHostSchemes && singleHostSchemes.length > 0) {
const parsed = parseSingleHostUri(
@@ -1412,6 +1482,9 @@ const ConnectionModal: React.FC<{
parsedValues.sslMode = "disable";
}
} else if (type === "clickhouse") {
parsedValues.clickHouseProtocol = normalizeClickHouseProtocolValue(
parsed.params.get("protocol"),
);
const secure = String(
parsed.params.get("secure") || parsed.params.get("tls") || "",
)
@@ -1707,7 +1780,18 @@ const ConnectionModal: React.FC<{
return `${scheme}://${encodedAuth}${hosts.join(",")}${dbPath}${query ? `?${query}` : ""}`;
}
const scheme = type === "postgres" ? "postgresql" : type;
const clickHouseProtocol =
type === "clickhouse"
? normalizeClickHouseProtocolValue(values.clickHouseProtocol)
: "auto";
const scheme =
type === "postgres"
? "postgresql"
: type === "clickhouse" && clickHouseProtocol === "http"
? values.useSSL
? "https"
: "http"
: type;
const dbPath = database ? `/${encodeURIComponent(database)}` : "";
const params = new URLSearchParams();
if (supportsSSLForType(type) && values.useSSL) {
@@ -1728,9 +1812,15 @@ const ConnectionModal: React.FC<{
mode === "skip-verify" || mode === "preferred" ? "true" : "false",
);
} else if (type === "clickhouse") {
params.set("secure", "true");
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
if (clickHouseProtocol === "http") {
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
} else {
params.set("secure", "true");
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
}
} else if (type === "dameng") {
const certPath = String(values.sslCertPath || "").trim();
@@ -1761,6 +1851,9 @@ const ConnectionModal: React.FC<{
params.set("protocol", "ws");
}
}
if (type === "clickhouse" && clickHouseProtocol !== "auto") {
params.set("protocol", clickHouseProtocol);
}
const query = params.toString();
return `${scheme}://${encodedAuth}${toAddress(host, port, defaultPort)}${dbPath}${query ? `?${query}` : ""}`;
};
@@ -1967,6 +2060,10 @@ const ConnectionModal: React.FC<{
password: config.password,
database: config.database,
uri: config.uri || "",
clickHouseProtocol:
configType === "clickhouse"
? normalizeClickHouseProtocolValue(config.clickHouseProtocol)
: "auto",
includeDatabases: initialValues.includeDatabases,
includeRedisDatabases: initialValues.includeRedisDatabases,
useSSL: !!config.useSSL,
@@ -2285,9 +2382,7 @@ const ConnectionModal: React.FC<{
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
message.warning(unavailableReason);
promptInstallDriver(values.type, unavailableReason);
@@ -2443,9 +2538,7 @@ const ConnectionModal: React.FC<{
try {
await form.validateFields();
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
);
const unavailableReason = await resolveDriverUnavailableReason(values.type);
if (unavailableReason) {
applyTestFailureFeedback(
resolveConnectionTestFailureFeedback({
@@ -2740,6 +2833,15 @@ const ConnectionModal: React.FC<{
(Array.isArray(value) && value.length === 0);
if (parsedUriValues) {
Object.entries(parsedUriValues).forEach(([key, value]) => {
if (
key === "clickHouseProtocol" &&
normalizeClickHouseProtocolValue((mergedValues as any)[key]) ===
"auto" &&
normalizeClickHouseProtocolValue(value) !== "auto"
) {
(mergedValues as any)[key] = value;
return;
}
if (isEmptyField((mergedValues as any)[key])) {
(mergedValues as any)[key] = value;
}
@@ -2748,6 +2850,35 @@ const ConnectionModal: React.FC<{
const type = String(mergedValues.type || "").toLowerCase();
const defaultPort = getDefaultPortByType(type);
if (type === "clickhouse") {
const requestedProtocol = normalizeClickHouseProtocolValue(
mergedValues.clickHouseProtocol,
);
const hostSchemeValues = parseClickHouseHTTPUriToValues(
mergedValues.host,
Number(mergedValues.port || defaultPort),
);
if (hostSchemeValues) {
mergedValues.host = hostSchemeValues.host;
mergedValues.port = hostSchemeValues.port;
if (requestedProtocol !== "native") {
mergedValues.clickHouseProtocol = "http";
mergedValues.useSSL = hostSchemeValues.useSSL;
mergedValues.sslMode = hostSchemeValues.sslMode;
} else {
mergedValues.clickHouseProtocol = "native";
}
if (isEmptyField(mergedValues.user)) {
mergedValues.user = hostSchemeValues.user;
}
if (isEmptyField(mergedValues.password)) {
mergedValues.password = hostSchemeValues.password;
}
if (isEmptyField(mergedValues.database)) {
mergedValues.database = hostSchemeValues.database;
}
}
}
const isFileDbType = isFileDatabaseType(type);
const sslCapableType = supportsSSLForType(type);
@@ -2990,6 +3121,10 @@ const ConnectionModal: React.FC<{
? Math.max(0, Math.min(15, Math.trunc(Number(mergedValues.redisDB))))
: 0,
uri: String(mergedValues.uri || "").trim(),
clickHouseProtocol:
type === "clickhouse"
? normalizeClickHouseProtocolValue(mergedValues.clickHouseProtocol)
: undefined,
hosts: hosts,
topology: topology,
mysqlReplicaUser: mysqlReplicaUser,
@@ -3017,7 +3152,10 @@ const ConnectionModal: React.FC<{
}
setTypeSelectWarning(null);
setDbType(type);
form.setFieldsValue({ type: type });
form.setFieldsValue({
type: type,
clickHouseProtocol: type === "clickhouse" ? "auto" : undefined,
});
const defaultPort = getDefaultPortByType(type);
if (type === "jvm") {
@@ -4294,6 +4432,25 @@ const ConnectionModal: React.FC<{
),
})}
{dbType === "clickhouse" &&
renderConfigSectionCard({
sectionKey: "connectionMode",
icon: <ClusterOutlined />,
children: (
<Form.Item
name="clickHouseProtocol"
label="连接协议"
help="自动模式按 URI scheme 和常见端口判断;非标 HTTP/Native 端口可手动指定。"
style={{ marginBottom: 0 }}
>
<Select
options={CLICKHOUSE_PROTOCOL_OPTIONS}
onChange={() => clearConnectionTestResultForChoice()}
/>
</Form.Item>
),
})}
{(dbType === "postgres" ||
dbType === "kingbase" ||
dbType === "highgo" ||

View File

@@ -187,6 +187,29 @@ describe('store appearance persistence', () => {
expect(useStore.getState().connections[0]?.iconColor).toBe('#2f855a');
});
it('normalizes ClickHouse protocol override when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'clickhouse-http',
name: 'ClickHouse HTTP',
config: {
id: 'clickhouse-http',
type: 'clickhouse',
host: 'clickhouse.local',
port: 8125,
user: 'default',
clickHouseProtocol: 'https' as any,
},
},
]);
expect(useStore.getState().connections[0]?.config.clickHouseProtocol).toBe(
'http',
);
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -163,6 +163,15 @@ const toTrimmedString = (value: unknown, fallback = ""): string => {
return fallback;
};
const normalizeClickHouseProtocol = (
value: unknown,
): "auto" | "http" | "native" => {
const text = toTrimmedString(value).toLowerCase();
if (text === "http" || text === "https") return "http";
if (text === "native" || text === "tcp") return "native";
return "auto";
};
const normalizePort = (value: unknown, fallbackPort: number): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallbackPort;
@@ -513,6 +522,12 @@ const sanitizeConnectionConfig = (value: unknown): ConnectionConfig => {
safeConfig.redisDB = normalizeIntegerInRange(raw.redisDB, 0, 0, 15);
}
if (type === "clickhouse") {
safeConfig.clickHouseProtocol = normalizeClickHouseProtocol(
raw.clickHouseProtocol,
);
}
if (type === "custom") {
safeConfig.driver = toTrimmedString(raw.driver);
safeConfig.dsn = toTrimmedString(raw.dsn).slice(0, MAX_URI_LENGTH);

View File

@@ -297,6 +297,7 @@ export interface ConnectionConfig {
timeout?: number;
redisDB?: number; // Redis database index (0-15)
uri?: string; // Connection URI for copy/paste
clickHouseProtocol?: "auto" | "http" | "native"; // ClickHouse connection protocol override
hosts?: string[]; // Multi-host addresses: host:port
topology?: "single" | "replica" | "cluster";
mysqlReplicaUser?: string;

View File

@@ -39,6 +39,19 @@ describe('buildRpcConnectionConfig', () => {
expect(result.database).toBe('app');
});
it('preserves ClickHouse protocol override for RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-clickhouse',
type: 'clickhouse',
host: 'clickhouse.local',
port: 8125,
user: 'default',
clickHouseProtocol: 'http',
} as any);
expect(result.clickHouseProtocol).toBe('http');
});
it('fills default nested config blocks needed by RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis',

View File

@@ -670,6 +670,7 @@ export namespace connection {
timeout?: number;
redisDB?: number;
uri?: string;
clickHouseProtocol?: string;
hosts?: string[];
topology?: string;
mysqlReplicaUser?: string;
@@ -712,6 +713,7 @@ export namespace connection {
this.timeout = source["timeout"];
this.redisDB = source["redisDB"];
this.uri = source["uri"];
this.clickHouseProtocol = source["clickHouseProtocol"];
this.hosts = source["hosts"];
this.topology = source["topology"];
this.mysqlReplicaUser = source["mysqlReplicaUser"];

View File

@@ -467,6 +467,13 @@ func formatConnSummary(config connection.ConnectionConfig) string {
b.WriteString(fmt.Sprintf(" 认证库=%s", strings.TrimSpace(config.AuthSource)))
}
}
if strings.EqualFold(strings.TrimSpace(config.Type), "clickhouse") {
protocol := strings.ToLower(strings.TrimSpace(config.ClickHouseProtocol))
if protocol == "" {
protocol = "auto"
}
b.WriteString(fmt.Sprintf(" ClickHouse协议=%s", protocol))
}
if config.UseSSH {
b.WriteString(fmt.Sprintf(" SSH=%s:%d 用户=%s", config.SSH.Host, config.SSH.Port, config.SSH.User))

View File

@@ -80,3 +80,22 @@ func TestGetCacheKey_KeepDatabaseIsolation(t *testing.T) {
t.Fatalf("expected different cache key for different database targets")
}
}
func TestGetCacheKey_KeepClickHouseProtocolIsolation(t *testing.T) {
base := connection.ConnectionConfig{
Type: "clickhouse",
Host: "clickhouse.local",
Port: 8125,
User: "default",
Database: "default",
ClickHouseProtocol: "native",
}
modified := base
modified.ClickHouseProtocol = "http"
left := getCacheKey(base)
right := getCacheKey(modified)
if left == right {
t.Fatalf("expected different cache key for different ClickHouse protocols")
}
}

View File

@@ -23,9 +23,24 @@ func normalizeTestConnectionConfig(config connection.ConnectionConfig) connectio
return normalized
}
func validateTestConnectionInput(config connection.ConnectionConfig) error {
dbType := strings.ToLower(strings.TrimSpace(config.Type))
if dbType == "" {
return fmt.Errorf("请先选择数据源类型")
}
if dbType == "clickhouse" && strings.TrimSpace(config.Host) == "" && strings.TrimSpace(config.URI) == "" {
return fmt.Errorf("请填写 ClickHouse 主机地址或连接 URI")
}
return nil
}
// Generic DB Methods
func (a *App) DBConnect(config connection.ConnectionConfig) connection.QueryResult {
if err := validateTestConnectionInput(config); err != nil {
logger.Warnf("DBConnect 参数校验失败:%s %s", err.Error(), formatConnSummary(config))
return connection.QueryResult{Success: false, Message: err.Error()}
}
// 连接测试需要强制 ping避免缓存命中但连接已失效时误判成功。
_, err := a.getDatabaseForcePing(config)
if err != nil {
@@ -41,6 +56,10 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer
testConfig := normalizeTestConnectionConfig(config)
started := time.Now()
logger.Infof("TestConnection 开始:%s", formatConnSummary(testConfig))
if err := validateTestConnectionInput(testConfig); err != nil {
logger.Warnf("TestConnection 参数校验失败:耗时=%s %s 原因=%s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig), err.Error())
return connection.QueryResult{Success: false, Message: err.Error()}
}
_, err := a.getDatabaseForcePing(testConfig)
if err != nil {
logger.Error(err, "TestConnection 连接测试失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig))

View File

@@ -31,6 +31,26 @@ func TestNormalizeTestConnectionConfig_ZeroTimeout(t *testing.T) {
}
}
func TestValidateTestConnectionInput_ClickHouseRequiresTarget(t *testing.T) {
err := validateTestConnectionInput(connection.ConnectionConfig{Type: "clickhouse"})
if err == nil {
t.Fatal("expected ClickHouse target validation error")
}
if !strings.Contains(err.Error(), "ClickHouse 主机地址") {
t.Fatalf("unexpected validation error: %v", err)
}
}
func TestValidateTestConnectionInput_ClickHouseAllowsURI(t *testing.T) {
err := validateTestConnectionInput(connection.ConnectionConfig{
Type: "clickhouse",
URI: "http://clickhouse.example.com:8125/default",
})
if err != nil {
t.Fatalf("expected ClickHouse URI to satisfy target validation, got %v", err)
}
}
func TestFormatConnSummary_BasicMySQL(t *testing.T) {
cfg := connection.ConnectionConfig{
Type: "mysql",

View File

@@ -102,6 +102,7 @@ type ConnectionConfig struct {
Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds (default: 30)
RedisDB int `json:"redisDB,omitempty"` // Redis database index (0-15)
URI string `json:"uri,omitempty"` // Connection URI for copy/paste
ClickHouseProtocol string `json:"clickHouseProtocol,omitempty"` // auto | http | native
Hosts []string `json:"hosts,omitempty"` // Multi-host addresses: host:port
Topology string `json:"topology,omitempty"` // single | replica | cluster
MySQLReplicaUser string `json:"mysqlReplicaUser,omitempty"` // MySQL replica auth user

View File

@@ -12,6 +12,8 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
@@ -26,7 +28,11 @@ const (
defaultClickHouseUser = "default"
defaultClickHouseDatabase = "default"
minClickHouseReadTimeout = 5 * time.Minute
clickHouseHTTPPortHint = "8123/8132/8443"
clickHouseHTTPPortHint = "8123/8125/8132/8443"
clickHouseProtocolAuto = "auto"
clickHouseProtocolHTTP = "http"
clickHouseProtocolNative = "native"
)
type ClickHouseDB struct {
@@ -38,6 +44,7 @@ type ClickHouseDB struct {
func normalizeClickHouseConfig(config connection.ConnectionConfig) connection.ConnectionConfig {
normalized := applyClickHouseURI(config)
normalized = applyClickHouseHostURI(normalized)
if strings.TrimSpace(normalized.Host) == "" {
normalized.Host = "localhost"
}
@@ -58,15 +65,26 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
if uriText == "" {
return config
}
lowerURI := strings.ToLower(uriText)
if !strings.HasPrefix(lowerURI, "clickhouse://") {
return applyClickHouseEndpointURI(config, uriText, false)
}
func applyClickHouseHostURI(config connection.ConnectionConfig) connection.ConnectionConfig {
hostText := strings.TrimSpace(config.Host)
if hostText == "" {
return config
}
return applyClickHouseEndpointURI(config, hostText, true)
}
func applyClickHouseEndpointURI(config connection.ConnectionConfig, uriText string, fromHostField bool) connection.ConnectionConfig {
parsed, err := url.Parse(uriText)
if err != nil {
return config
}
scheme := strings.ToLower(strings.TrimSpace(parsed.Scheme))
if !isClickHouseSupportedEndpointScheme(scheme) || strings.TrimSpace(parsed.Host) == "" {
return config
}
if parsed.User != nil {
if strings.TrimSpace(config.User) == "" {
@@ -85,12 +103,28 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
config.Database = dbName
}
}
if queryProtocol := normalizeClickHouseProtocol(parsed.Query().Get("protocol")); queryProtocol != clickHouseProtocolAuto {
config.ClickHouseProtocol = queryProtocol
}
endpointProtocol := normalizeClickHouseProtocol(config.ClickHouseProtocol)
if isClickHouseHTTPURLScheme(scheme) && endpointProtocol != clickHouseProtocolNative {
config.ClickHouseProtocol = clickHouseProtocolHTTP
if scheme == "https" {
config.UseSSL = true
if normalizeSSLModeValue(config.SSLMode) == sslModeDisable || strings.TrimSpace(config.SSLMode) == "" {
config.SSLMode = sslModeRequired
}
}
}
defaultPort := config.Port
if defaultPort <= 0 {
defaultPort = defaultClickHousePort
}
if strings.TrimSpace(config.Host) == "" {
if isClickHouseHTTPURLScheme(scheme) && endpointProtocol != clickHouseProtocolNative && defaultPort == defaultClickHousePort {
defaultPort = defaultClickHousePortForScheme(scheme)
}
if fromHostField || strings.TrimSpace(config.Host) == "" {
host, port, ok := parseHostPortWithDefault(parsed.Host, defaultPort)
if ok {
config.Host = host
@@ -103,6 +137,30 @@ func applyClickHouseURI(config connection.ConnectionConfig) connection.Connectio
return config
}
func isClickHouseSupportedEndpointScheme(scheme string) bool {
switch scheme {
case "clickhouse", "http", "https":
return true
default:
return false
}
}
func isClickHouseHTTPURLScheme(scheme string) bool {
return scheme == "http" || scheme == "https"
}
func defaultClickHousePortForScheme(scheme string) int {
switch scheme {
case "http":
return 8123
case "https":
return 8443
default:
return defaultClickHousePort
}
}
func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig) *clickhouse.Options {
connectTimeout := getConnectTimeout(config)
readTimeout := connectTimeout
@@ -130,6 +188,15 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
}
func detectClickHouseProtocol(config connection.ConnectionConfig) clickhouse.Protocol {
switch normalizeClickHouseProtocol(config.ClickHouseProtocol) {
case clickHouseProtocolHTTP:
return clickhouse.HTTP
case clickHouseProtocolNative:
return clickhouse.Native
}
if hasClickHouseHTTPScheme(config.URI) || hasClickHouseHTTPScheme(config.Host) {
return clickhouse.HTTP
}
uriText := strings.ToLower(strings.TrimSpace(config.URI))
if strings.HasPrefix(uriText, "http://") || strings.HasPrefix(uriText, "https://") {
return clickhouse.HTTP
@@ -140,9 +207,25 @@ func detectClickHouseProtocol(config connection.ConnectionConfig) clickhouse.Pro
return clickhouse.Native
}
func normalizeClickHouseProtocol(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case clickHouseProtocolHTTP, "https":
return clickHouseProtocolHTTP
case clickHouseProtocolNative, "tcp":
return clickHouseProtocolNative
default:
return clickHouseProtocolAuto
}
}
func hasClickHouseHTTPScheme(raw string) bool {
text := strings.ToLower(strings.TrimSpace(raw))
return strings.HasPrefix(text, "http://") || strings.HasPrefix(text, "https://")
}
func isClickHouseHTTPPort(port int) bool {
switch port {
case 8123, 8132, 8443:
case 8123, 8125, 8132, 8443:
return true
default:
return false
@@ -159,18 +242,88 @@ func isClickHouseProtocolMismatch(err error) bool {
}
return strings.Contains(text, "unexpected packet [72]") ||
(strings.Contains(text, "unexpected packet") && strings.Contains(text, "handshake")) ||
(strings.Contains(text, "cannot parse input") && strings.Contains(text, "expected '('")) ||
strings.Contains(text, "http response to https client") ||
strings.Contains(text, "malformed http response")
}
func clickHouseProtocolName(protocol clickhouse.Protocol) string {
if protocol == clickhouse.HTTP {
return "HTTP"
}
return "Native"
}
func sanitizeClickHouseErrorMessage(err error) string {
if err == nil {
return ""
}
text := strings.ToValidUTF8(err.Error(), "<22>")
var b strings.Builder
lastSpace := false
for _, r := range text {
if r == utf8.RuneError || r == '<27>' {
if !lastSpace {
b.WriteByte(' ')
lastSpace = true
}
continue
}
if unicode.IsControl(r) {
if !lastSpace {
b.WriteByte(' ')
lastSpace = true
}
continue
}
b.WriteRune(r)
lastSpace = unicode.IsSpace(r)
}
sanitized := strings.Join(strings.Fields(b.String()), " ")
if len(sanitized) > 320 {
return sanitized[:320] + "..."
}
return sanitized
}
func clickHouseAttemptFailureMessage(protocol clickhouse.Protocol, err error) string {
if isClickHouseProtocolMismatch(err) {
if protocol == clickhouse.Native {
return "服务端响应不像 Native 握手,当前端口更像 HTTP/HTTPS 端口;请选择 HTTP 协议,或确认 ClickHouse Native 端口"
}
return "服务端响应不像 HTTP 响应,当前端口更像 Native 端口;请选择 Native 协议,或确认 ClickHouse HTTP 端口"
}
message := sanitizeClickHouseErrorMessage(err)
if message == "" {
return "未知错误"
}
return message
}
func clickHouseConnectFailureSummary(config connection.ConnectionConfig, failures []string) string {
protocolMode := normalizeClickHouseProtocol(config.ClickHouseProtocol)
detail := strings.Join(failures, "")
if strings.TrimSpace(detail) == "" {
detail = "未获取到驱动返回的错误详情"
}
if protocolMode != clickHouseProtocolAuto {
return fmt.Sprintf("ClickHouse 连接验证失败:已按用户选择使用 %s 协议连接 %s:%d。%s",
strings.ToUpper(protocolMode), config.Host, config.Port, detail)
}
return fmt.Sprintf("ClickHouse 连接验证失败自动协议探测未成功Native 常见端口 9000/9440HTTP 常见端口 %s非标端口建议在连接协议中手动指定。%s",
clickHouseHTTPPortHint, detail)
}
func withClickHouseProtocol(config connection.ConnectionConfig, protocol clickhouse.Protocol) connection.ConnectionConfig {
next := config
switch protocol {
case clickhouse.HTTP:
next.ClickHouseProtocol = clickHouseProtocolHTTP
if next.Port == 0 {
next.Port = 8123
}
default:
next.ClickHouseProtocol = clickHouseProtocolNative
if next.Port == 0 {
next.Port = defaultClickHousePort
}
@@ -178,6 +331,17 @@ func withClickHouseProtocol(config connection.ConnectionConfig, protocol clickho
return next
}
func clickHouseProtocolsForAttempt(config connection.ConnectionConfig) []clickhouse.Protocol {
primaryProtocol := detectClickHouseProtocol(config)
if normalizeClickHouseProtocol(config.ClickHouseProtocol) != clickHouseProtocolAuto {
return []clickhouse.Protocol{primaryProtocol}
}
if primaryProtocol == clickhouse.Native {
return []clickhouse.Protocol{primaryProtocol, clickhouse.HTTP}
}
return []clickhouse.Protocol{primaryProtocol, clickhouse.Native}
}
func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
if supported, reason := DriverRuntimeSupportStatus("clickhouse"); !supported {
if strings.TrimSpace(reason) == "" {
@@ -198,8 +362,14 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
runConfig := normalizeClickHouseConfig(config)
c.pingTimeout = getConnectTimeout(runConfig)
c.database = runConfig.Database
logger.Infof("ClickHouse 连接准备:地址=%s:%d 数据库=%s 用户=%s 协议选择=%s SSL=%t SSH=%t 超时=%s",
runConfig.Host, runConfig.Port, runConfig.Database, runConfig.User,
normalizeClickHouseProtocol(runConfig.ClickHouseProtocol), runConfig.UseSSL, runConfig.UseSSH, c.pingTimeout)
if runConfig.UseSSH {
if normalizeClickHouseProtocol(runConfig.ClickHouseProtocol) == clickHouseProtocolAuto && detectClickHouseProtocol(runConfig) == clickhouse.HTTP {
runConfig.ClickHouseProtocol = clickHouseProtocolHTTP
}
logger.Infof("ClickHouse 使用 SSH 连接:地址=%s:%d 用户=%s", runConfig.Host, runConfig.Port, runConfig.User)
forwarder, err := ssh.GetOrCreateLocalForwarder(runConfig.SSH, runConfig.Host, runConfig.Port)
if err != nil {
@@ -229,19 +399,17 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
var failures []string
for idx, attempt := range attempts {
primaryProtocol := detectClickHouseProtocol(attempt)
protocols := []clickhouse.Protocol{primaryProtocol}
if primaryProtocol == clickhouse.Native {
protocols = append(protocols, clickhouse.HTTP)
} else {
protocols = append(protocols, clickhouse.Native)
}
protocols := clickHouseProtocolsForAttempt(attempt)
for pIdx, protocol := range protocols {
protocolConfig := withClickHouseProtocol(attempt, protocol)
logger.Infof("ClickHouse 连接尝试:第%d组/%d 协议=%s 地址=%s:%d SSL=%t",
idx+1, len(attempts), clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.UseSSL)
c.conn = clickhouse.OpenDB(c.buildClickHouseOptions(protocolConfig))
if err := c.Ping(); err != nil {
failures = append(failures, fmt.Sprintf("第%d次连接验证失败(protocol=%s): %v", idx+1, protocol.String(), err))
failureMessage := clickHouseAttemptFailureMessage(protocol, err)
failures = append(failures, fmt.Sprintf("第%d次连接验证失败(protocol=%s): %s", idx+1, protocol.String(), failureMessage))
logger.Warnf("ClickHouse 连接尝试失败:第%d组/%d 协议=%s 地址=%s:%d SSL=%t 原因=%s",
idx+1, len(attempts), clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.UseSSL, failureMessage)
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
@@ -258,12 +426,13 @@ func (c *ClickHouseDB) Connect(config connection.ConnectionConfig) error {
if pIdx > 0 {
logger.Warnf("ClickHouse 已自动切换连接协议为 %s常见于 %s HTTP 端口)", protocol.String(), clickHouseHTTPPortHint)
}
logger.Infof("ClickHouse 连接验证成功:协议=%s 地址=%s:%d 数据库=%s", clickHouseProtocolName(protocol), protocolConfig.Host, protocolConfig.Port, protocolConfig.Database)
return nil
}
}
_ = c.Close()
return fmt.Errorf("连接建立后验证失败(可检查 ClickHouse 端口与协议是否匹配Native=9000/9440HTTP=%s%s", clickHouseHTTPPortHint, strings.Join(failures, ""))
return fmt.Errorf("%s", clickHouseConnectFailureSummary(runConfig, failures))
}
func (c *ClickHouseDB) Close() error {

View File

@@ -160,6 +160,13 @@ func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
},
expected: clickhouse.HTTP,
},
{
name: "custom http port 8125",
config: connection.ConnectionConfig{
Port: 8125,
},
expected: clickhouse.HTTP,
},
{
name: "https port",
config: connection.ConnectionConfig{
@@ -181,6 +188,30 @@ func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
},
expected: clickhouse.Native,
},
{
name: "host http scheme",
config: connection.ConnectionConfig{
Host: "http://clickhouse.example.com",
Port: 8125,
},
expected: clickhouse.HTTP,
},
{
name: "manual http overrides native port",
config: connection.ConnectionConfig{
ClickHouseProtocol: "http",
Port: 9000,
},
expected: clickhouse.HTTP,
},
{
name: "manual native overrides http port",
config: connection.ConnectionConfig{
ClickHouseProtocol: "native",
Port: 8123,
},
expected: clickhouse.Native,
},
}
for _, tt := range tests {
@@ -192,6 +223,172 @@ func TestDetectClickHouseProtocolTreatsHTTPPortsAsHTTP(t *testing.T) {
}
}
func TestNormalizeClickHouseConfigParsesHTTPHostScheme(t *testing.T) {
config := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
Host: "https://clickhouse.example.com:8125/analytics",
User: "alice",
Password: "secret",
})
if config.Host != "clickhouse.example.com" {
t.Fatalf("expected host without scheme, got %q", config.Host)
}
if config.Port != 8125 {
t.Fatalf("expected port 8125, got %d", config.Port)
}
if config.Database != "analytics" {
t.Fatalf("expected database analytics, got %q", config.Database)
}
if config.ClickHouseProtocol != "http" {
t.Fatalf("expected http protocol hint, got %q", config.ClickHouseProtocol)
}
if !config.UseSSL || config.SSLMode != sslModeRequired {
t.Fatalf("expected https host to enable required SSL, got useSSL=%v sslMode=%q", config.UseSSL, config.SSLMode)
}
}
func TestNormalizeClickHouseConfigKeepsManualNativeWhenHostHasHTTPScheme(t *testing.T) {
config := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
Host: "http://clickhouse.example.com:9001/analytics",
ClickHouseProtocol: "native",
User: "alice",
Password: "secret",
})
if config.Host != "clickhouse.example.com" {
t.Fatalf("expected host without scheme, got %q", config.Host)
}
if config.Port != 9001 {
t.Fatalf("expected user-provided native port 9001, got %d", config.Port)
}
if config.Database != "analytics" {
t.Fatalf("expected database analytics, got %q", config.Database)
}
if config.ClickHouseProtocol != "native" {
t.Fatalf("expected manual native protocol to be preserved, got %q", config.ClickHouseProtocol)
}
if config.UseSSL {
t.Fatalf("manual native protocol should not be forced to HTTP TLS by http scheme")
}
}
func TestNormalizeClickHouseConfigUsesNativeDefaultPortForManualNativeHTTPScheme(t *testing.T) {
config := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
Host: "https://clickhouse.example.com/analytics",
ClickHouseProtocol: "native",
})
if config.Host != "clickhouse.example.com" {
t.Fatalf("expected host without scheme, got %q", config.Host)
}
if config.Port != defaultClickHousePort {
t.Fatalf("expected native default port %d, got %d", defaultClickHousePort, config.Port)
}
if config.ClickHouseProtocol != "native" {
t.Fatalf("expected manual native protocol to be preserved, got %q", config.ClickHouseProtocol)
}
}
func TestClickHouseProtocolMismatchIncludesHTTPParseBinaryResponse(t *testing.T) {
err := errors.New("code: 27, message: Cannot parse input: expected '(' before: '\x02\x00\x01\x00'")
if !isClickHouseProtocolMismatch(err) {
t.Fatalf("expected binary parse response to be treated as protocol mismatch")
}
message := clickHouseAttemptFailureMessage(clickhouse.Native, err)
if !strings.Contains(message, "不像 Native") || strings.Contains(message, "\x00") {
t.Fatalf("expected user-facing native mismatch message without binary bytes, got %q", message)
}
}
func TestWithClickHouseProtocolForcesProtocolSelection(t *testing.T) {
httpConfig := withClickHouseProtocol(connection.ConnectionConfig{
Type: "clickhouse",
Host: "clickhouse.example.com",
Port: 8125,
}, clickhouse.HTTP)
if protocol := detectClickHouseProtocol(httpConfig); protocol != clickhouse.HTTP {
t.Fatalf("expected forced HTTP protocol, got %s", protocol.String())
}
nativeConfig := withClickHouseProtocol(connection.ConnectionConfig{
Type: "clickhouse",
Host: "http://clickhouse.example.com",
Port: 8125,
}, clickhouse.Native)
if protocol := detectClickHouseProtocol(nativeConfig); protocol != clickhouse.Native {
t.Fatalf("expected forced Native protocol, got %s", protocol.String())
}
}
func TestClickHouseProtocolsForAttemptOnlyFallsBackInAutoMode(t *testing.T) {
tests := []struct {
name string
config connection.ConnectionConfig
expected []clickhouse.Protocol
}{
{
name: "auto native falls back to http",
config: connection.ConnectionConfig{
Type: "clickhouse",
Port: 9000,
},
expected: []clickhouse.Protocol{clickhouse.Native, clickhouse.HTTP},
},
{
name: "auto http falls back to native",
config: connection.ConnectionConfig{
Type: "clickhouse",
Port: 8125,
},
expected: []clickhouse.Protocol{clickhouse.HTTP, clickhouse.Native},
},
{
name: "manual http does not try native",
config: connection.ConnectionConfig{
Type: "clickhouse",
Port: 9000,
ClickHouseProtocol: "http",
},
expected: []clickhouse.Protocol{clickhouse.HTTP},
},
{
name: "manual native does not try http",
config: connection.ConnectionConfig{
Type: "clickhouse",
Port: 8125,
ClickHouseProtocol: "native",
},
expected: []clickhouse.Protocol{clickhouse.Native},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clickHouseProtocolsForAttempt(tt.config)
if len(got) != len(tt.expected) {
t.Fatalf("expected protocols %v, got %v", protocolNames(tt.expected), protocolNames(got))
}
for idx := range got {
if got[idx] != tt.expected[idx] {
t.Fatalf("expected protocols %v, got %v", protocolNames(tt.expected), protocolNames(got))
}
}
})
}
}
func protocolNames(protocols []clickhouse.Protocol) []string {
names := make([]string, 0, len(protocols))
for _, protocol := range protocols {
names = append(names, protocol.String())
}
return names
}
type fakeClickHouseDriver struct{}
func (fakeClickHouseDriver) Open(name string) (driver.Conn, error) {