使用Haskell构建类型安全的OLAP查询层并对接Solid.js实现海量数据渲染


我们面临一个棘手的问题:业务方需要一个高度灵活的数据探索界面,能够对数仓中数十亿行的日志数据进行即时聚合、筛选和分析。现有的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-simplequery函数和?占位符。

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的createSignalcreateStore来管理这个状态。

// 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()


  目录