我们面临一个棘手的问题:业务方需要一个高度灵活的数据探索界面,能够对数仓中数十亿行的日志数据进行即时聚合、筛选和分析。现有的BI工具要么过于笨重,要么无法满足定制化的查询需求。而自研方案最大的痛点在于前端和后端之间那条脆弱的、基于字符串拼接SQL的通信链路。任何数据仓库表的变更,或是前端查询逻辑的微调,都可能导致后端API的崩溃或返回错误数据,整个调试过程苦不堪言。
在真实项目中,这种前后端契约的脆弱性是巨大的技术债。前端发送一个结构复杂的JSON,后端解析后,小心翼翼地拼接成SQL字符串。这个过程不仅容易引入SQL注入风险,而且毫无类型安全可言。我们需要的不是一个简单的仪表盘,而是一个健壮的、类型安全的、从用户界面到底层数据仓库的端到端查询构建器。
最初的构想是,能否用一种强类型语言在后端将查询逻辑本身模型化?我们不传递JSON,而是传递一个代表查询意图的、结构化的、类型安全的对象。后端接收这个对象,将其确定性地、安全地编译成SQL。这个想法直接将我们引向了Haskell。它的代数数据类型(ADT)是为此类问题量身定做的。
前端则需要极致的渲染性能。当一个聚合查询可能返回数万甚至数十万行结果时,任何基于Virtual DOM的框架(如React或Vue)都会在渲染和更新上遇到瓶颈。我们需要的是能够直接操作DOM、实现细粒度更新的方案。Solid.js,凭借其无V-DOM的编译时响应式模型,成为了最理想的选择。
因此,技术栈选型确定:ClickHouse作为高性能OLAP数据仓库,Haskell (使用Servant框架) 作为类型安全的API层,Solid.js作为高性能的前端渲染层。这个组合看起来非主流,但在解决我们特定的“类型安全”和“极致性能”这两个核心痛点上,却异常契合。
架构概览
整个数据流和交互逻辑可以用下面的架构图来表示。用户在Solid.js前端通过UI组件构建一个查询对象,这个对象通过API发送给Haskell后端。后端验证并将其编译为ClickHouse的SQL,执行查询后将结果流式返回给前端,由虚拟滚动列表进行高效渲染。
sequenceDiagram
participant User
participant SolidJS_Frontend as Solid.js UI
participant Haskell_Backend as Haskell Servant API
participant ClickHouse_DW as ClickHouse Data Warehouse
User->>SolidJS_Frontend: 通过UI构建查询 (选择维度、指标、过滤器)
SolidJS_Frontend->>SolidJS_Frontend: 将UI状态同步到内部查询对象 (Signal)
SolidJS_Frontend->>Haskell_Backend: POST /query (发送类型化的查询对象JSON)
Haskell_Backend->>Haskell_Backend: 解析JSON为Haskell ADT (Query)
Note right of Haskell_Backend: 类型安全保证,解析失败则返回400
Haskell_Backend->>Haskell_Backend: translateQuery(Query) -> SQL Text
Haskell_Backend->>ClickHouse_DW: 执行生成的SQL查询
ClickHouse_DW-->>Haskell_Backend: 返回查询结果数据流
Haskell_Backend-->>SolidJS_Frontend: 流式返回JSON结果
SolidJS_Frontend->>SolidJS_Frontend: 接收数据并渲染到虚拟滚动列表
SolidJS_Frontend-->>User: 显示查询结果
Haskell后端:构建类型安全的查询编译层
后端的任务是定义查询模型,并提供一个能将此模型安全地转换为SQL的API。
1. 定义查询的代数数据类型 (ADT)
这是整个方案的基石。我们使用Haskell的类型系统来精确描述一个OLAP查询的构成部分。
-- file: src/QueryModel.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE OverloadedStrings #-}
module QueryModel where
import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import GHC.Generics (Generic)
-- 支持的聚合函数
data AggregationFunction = Count | Sum | Avg | Min | Max
deriving (Show, Eq, Generic)
instance FromJSON AggregationFunction
instance ToJSON AggregationFunction
-- 一个指标,由聚合函数和列名构成
data Metric = Metric
{ func :: AggregationFunction,
column :: Text
}
deriving (Show, Eq, Generic)
instance FromJSON Metric
instance ToJSON Metric
-- 过滤器操作符
data FilterOperator = Eq | Neq | Gt | Lt | Gte | Lte | In
deriving (Show, Eq, Generic)
instance FromJSON FilterOperator
instance ToJSON FilterOperator
-- 过滤条件的值
data FilterValue = TextValue Text | IntValue Int | TextList [Text] | IntList [Int]
deriving (Show, Eq, Generic)
instance FromJSON FilterValue
instance ToJSON FilterValue
-- 一个完整的过滤条件
data Filter = Filter
{ fColumn :: Text,
fOp :: FilterOperator,
fValue :: FilterValue
}
deriving (Show, Eq, Generic)
instance FromJSON Filter
instance ToJSON Filter
-- 排序方向
data SortDirection = Asc | Desc
deriving (Show, Eq, Generic)
instance FromJSON SortDirection
instance ToJSON SortDirection
-- 排序条件
data SortBy = SortBy
{ sColumn :: Text,
sDirection :: SortDirection
}
deriving (Show, Eq, Generic)
instance FromJSON SortBy
instance ToJSON SortBy
-- 完整的OLAP查询模型
data OlapQuery = OlapQuery
{ dimensions :: [Text], -- 分组维度
metrics :: [Metric], -- 聚合指标
filters :: [Filter], -- 过滤条件
sortBy :: Maybe [SortBy], -- 排序
limit :: Maybe Int, -- 结果限制
offset :: Maybe Int -- 结果偏移
}
deriving (Show, Eq, Generic)
instance FromJSON OlapQuery
instance ToJSON OlapQuery
这里的坑在于,FilterValue的设计必须能处理不同类型的值,比如字符串、数字和列表(用于IN操作),ADT是完美的选择。
2. 从查询模型到SQL的翻译器
这个纯函数是后端的核心逻辑。它接收OlapQuery,返回一个安全的、参数化的SQL查询字符串。我们刻意避免直接拼接用户输入,而是通过模式匹配和安全的辅助函数来构建SQL,从根本上杜绝SQL注入。
-- file: src/SqlGenerator.hs
{-# LANGUAGE OverloadedStrings #-}
module SqlGenerator (translateQuery) where
import Data.Text (Text, pack, intercalate, toLower)
import QueryModel
-- 主翻译函数
translateQuery :: Text -> OlapQuery -> Text
translateQuery tableName query =
let selectClause' = selectClause (dimensions query) (metrics query)
fromClause' = "FROM " <> tableName
whereClause' = whereClause (filters query)
groupByClause' = groupByClause (dimensions query)
orderByClause' = orderByClause (sortBy query)
limitClause' = limitClause (limit query)
offsetClause' = offsetClause (offset query)
in intercalate " "
[ selectClause',
fromClause',
whereClause',
groupByClause',
orderByClause',
limitClause',
offsetClause'
]
-- 构建SELECT子句
selectClause :: [Text] -> [Metric] -> Text
selectClause dims mets = "SELECT " <> intercalate ", " (dims <> map metricToSql mets)
-- 构建WHERE子句
whereClause :: [Filter] -> Text
whereClause [] = ""
whereClause fs = "WHERE " <> intercalate " AND " (map filterToSql fs)
-- 构建GROUP BY子句
groupByClause :: [Text] -> Text
groupByClause [] = ""
groupByClause dims = "GROUP BY " <> intercalate ", " dims
-- 构建ORDER BY子句
orderByClause :: Maybe [SortBy] -> Text
orderByClause Nothing = ""
orderByClause (Just []) = ""
orderByClause (Just sbs) = "ORDER BY " <> intercalate ", " (map sortByToSql sbs)
-- 构建LIMIT/OFFSET子句
limitClause :: Maybe Int -> Text
limitClause Nothing = ""
limitClause (Just l) = "LIMIT " <> pack (show l)
offsetClause :: Maybe Int -> Text
offsetClause Nothing = ""
offsetClause (Just o) = "OFFSET " <> pack (show o)
-- 内部辅助函数,用于将各个部分转换为SQL片段
metricToSql :: Metric -> Text
metricToSql (Metric func col) = aggFuncToSql func <> "(" <> col <> ") AS " <> toLower (pack (show func)) <> "_" <> col
aggFuncToSql :: AggregationFunction -> Text
aggFuncToSql Count = "count"
aggFuncToSql Sum = "sum"
aggFuncToSql Avg = "avg"
aggFuncToSql Min = "min"
aggFuncToSql Max = "max"
filterToSql :: Filter -> Text
filterToSql (Filter col op val) = col <> " " <> opToSql op <> " " <> valueToSql val
opToSql :: FilterOperator -> Text
opToSql Eq = "="
opToSql Neq = "!="
opToSql Gt = ">"
opToSql Lt = "<"
opToSql Gte = ">="
opToSql Lte = "<="
opToSql In = "IN"
valueToSql :: FilterValue -> Text
valueToSql (TextValue t) = "'" <> t <> "'" -- 注意:生产环境应使用参数化查询,这里为简化示例
valueToSql (IntValue i) = pack (show i)
valueTotSql (TextList ts) = "(" <> intercalate ", " (map (\t -> "'" <> t <> "'") ts) <> ")"
valueToSql (IntList is) = "(" <> intercalate ", " (map (pack . show) is) <> ")"
sortByToSql :: SortBy -> Text
sortByToSql (SortBy col dir) = col <> " " <> directionToSql dir
directionToSql :: SortDirection -> Text
directionToSql Asc = "ASC"
directionToSql Desc = "DESC"
生产级注意点: 上述 valueToSql 为了演示清晰,直接拼接了值。在真实项目中,这绝对是错误的做法。必须使用数据库驱动提供的参数化查询功能,将查询模板和值分开发送给数据库。例如,使用postgresql-simple的query函数和?占位符。
3. 使用Servant定义API
Servant允许我们用Haskell的类型来定义API的结构,这使得API的实现和文档的生成都变得极为可靠。
-- file: src/Api.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeOperators #-}
module Api where
import Data.Aeson (Value)
import Data.Text (Text)
import Servant
import QueryModel
-- 定义API类型
-- POST /query 端点,请求体是 OlapQuery,返回一个JSON数组
type OlapAPI = "query" :> ReqBody '[JSON] OlapQuery :> Post '[JSON] [Value]
-- 创建一个代理
olapApi :: Proxy OlapAPI
olapApi = Proxy
4. API服务器实现
实现API处理器,它将连接数据库、执行查询并返回结果。
-- file: app/main.hs
{-# LANGUAGE OverloadedStrings #-}
import Network.Wai.Handler.Warp (run)
import Servant
import Api (OlapAPI, olapApi)
import QueryModel (OlapQuery)
import SqlGenerator (translateQuery)
import Data.Aeson (Value)
import Database.ClickHouse -- 假设使用了一个叫 'clickhouse-haskell' 的库
import Control.Monad.IO.Class (liftIO)
import Data.Text (unpack)
-- 模拟的数据库连接配置
dbConfig :: ClickHouseConnection
dbConfig = undefined -- 在此配置你的ClickHouse连接信息
server :: Server OlapAPI
server = handleQuery
where
handleQuery :: OlapQuery -> Handler [Value]
handleQuery query = do
-- 日志记录收到的查询
liftIO $ putStrLn $ "Received query: " ++ show query
-- 将OlapQuery翻译成SQL
let sql = translateQuery "logs_table" query
liftIO $ putStrLn $ "Generated SQL: " ++ unpack sql
-- 连接数据库并执行查询
-- 这是一个简化的示例,真实应用需要连接池和更健壮的错误处理
result <- liftIO $ runClickHouseQuery dbConfig sql
case result of
Left err -> do
liftIO $ putStrLn $ "Query failed: " ++ show err
throwError err500 { errBody = "Database query failed." }
Right rows -> return rows -- 假设驱动返回 [Value]
app :: Application
app = serve olapApi server
main :: IO ()
main = run 8080 app
这个实现展示了整个流程:接收请求、翻译查询、执行并返回。一个常见的错误是在Handler中处理了过多的业务逻辑,正确的做法是将其抽象到独立的模块中,并进行单元测试,尤其是SqlGenerator模块。
Solid.js前端:高性能的数据展示
前端的核心挑战在于两点:1) 构建一个与后端类型模型对应的响应式查询状态;2) 高效渲染可能非常庞大的结果集。
1. 状态管理与类型定义
我们使用TypeScript来定义与Haskell ADT对应的查询对象,并用Solid的createSignal或createStore来管理这个状态。
// file: src/queryStore.ts
import { createStore } from 'solid-js/store';
// 与Haskell ADT对应的TypeScript类型
export type AggregationFunction = 'Count' | 'Sum' | 'Avg' | 'Min' | 'Max';
export type FilterOperator = 'Eq' | 'Neq' | 'Gt' | 'Lt' | 'Gte' | 'Lte' | 'In';
export type FilterValue = string | number | string[] | number[];
export interface Metric {
func: AggregationFunction;
column: string;
}
export interface Filter {
fColumn: string;
fOp: FilterOperator;
fValue: FilterValue;
}
// ... 其他类型 SortBy, SortDirection ...
export interface OlapQuery {
dimensions: string[];
metrics: Metric[];
filters: Filter[];
sortBy: any[] | null;
limit: number | null;
offset: number | null;
}
// 使用Solid store来管理复杂的查询状态
const [query, setQuery] = createStore<OlapQuery>({
dimensions: ['event_type'],
metrics: [{ func: 'Count', column: '*' }],
filters: [],
sortBy: null,
limit: 1000,
offset: 0,
});
export { query, setQuery };
2. 查询构建器UI
UI组件的职责就是修改上面定义的query store。例如,一个维度选择器组件:
// file: src/components/DimensionSelector.tsx
import { For, createEffect } from 'solid-js';
import { query, setQuery } from '../queryStore';
const ALL_DIMENSIONS = ['user_id', 'event_type', 'country_code', 'path'];
export function DimensionSelector() {
const handleCheckboxChange = (dimension: string, checked: boolean) => {
if (checked) {
setQuery('dimensions', (dims) => [...dims, dimension]);
} else {
setQuery('dimensions', (dims) => dims.filter((d) => d !== dimension));
}
};
return (
<div>
<h4>Dimensions</h4>
<For each={ALL_DIMENSIONS}>
{(dim) => (
<div>
<input
type="checkbox"
id={`dim-${dim}`}
checked={query.dimensions.includes(dim)}
onChange={(e) => handleCheckboxChange(dim, e.currentTarget.checked)}
/>
<label for={`dim-${dim}`}>{dim}</label>
</div>
)}
</For>
</div>
);
}
3. 数据获取与虚拟滚动渲染
当query状态变化时,我们需要触发API请求。createResource是Solid.js中处理异步数据的标准方式。结果的渲染则必须使用虚拟列表。
```tsx
// file: src/components/ResultsTable.tsx
import { createResource, For, Show } from ‘solid-js’;
import { query } from ‘../queryStore’;
import { createVirtualizer } from ‘@tanstack/solid-virtual’;
const fetchResults = async (q: OlapQuery) => {
try {
const response = await fetch(‘/api/query’, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’ },
body: JSON.stringify(q),
});
if (!response.ok) {
throw new Error(API Error: ${response.statusText});
}
return (await response.json()) as Record<string, any>[];
} catch (err) {
console.error(“Failed to fetch query results:”, err);
// 在真实应用中,这里应该有更完善的UI错误提示
return [];
}
};
export function ResultsTable() {
// 当query变化时,createResource会自动重新发起请求
const [data] = createResource(() => query, fetchResults);
let parentRef: HTMLDivElement | undefined;
const rowVirtualizer = createVirtualizer({
count: () => data()?.length ?? 0,
getScrollElement: () => parentRef,
estimateSize: () => 35, // 预估每行的高度
overscan: 5, // 额外渲染的行数
});
const columns = () => {
if (!data() || data()!.length === 0) return [];
return Object.keys(data()