Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第六章:扫描引擎设计

本章介绍 VSDB 的自动扫描引擎,它能从项目配置文件中自动发现数据库连接。

6.1 扫描引擎概述

功能定位

VSDB 扫描引擎解决的核心问题:

graph TD
    subgraph "传统方式"
        A1["连接信息分散"] --> A2["手动配置"]
        A2 --> A3["重复劳动"]
        A3 --> A4["团队协作困难"]
    end
    
    subgraph "VSDB 扫描引擎"
        B1["自动发现连接"] --> B2["一键添加"]
        B2 --> B3["团队共享配置"]
        B3 --> B4["效率提升"]
    end

支持的扫描来源

来源文件模式解析难度信息完整度
.env.env, .env.local, .env.*高(DATABASE_URL)
docker-composedocker-compose.yml高(服务定义)
TypeORMormconfig.json, data-source.ts
Laravelconfig/database.php
Djangosettings.py
Prismaschema.prisma

6.2 扫描引擎架构

ScannerEngine 协调器

export class ScannerEngine {
  private envParser = new EnvParser();
  private dockerComposeParser = new DockerComposeParser();
  private frameworkParser = new FrameworkParser();
  
  async scan(
    root: string, 
    existingConnections: DbConnection[]
  ): Promise<ScannerResult> {
    const results: ScannerResult = {
      connections: [],
      errors: [],
      scannedFiles: [],
    };
    
    // 执行各 Parser
    const envResult = await this.envParser.scan(root);
    const dockerResult = await this.dockerComposeParser.scan(root);
    const frameworkResult = await this.frameworkParser.scan(root);
    
    // 合并结果
    results.connections.push(...envResult.connections);
    results.connections.push(...dockerResult.connections);
    results.connections.push(...frameworkResult.connections);
    
    results.errors.push(...envResult.errors);
    results.errors.push(...dockerResult.errors);
    results.errors.push(...frameworkResult.errors);
    
    results.scannedFiles.push(...envResult.scannedFiles);
    results.scannedFiles.push(...dockerResult.scannedFiles);
    results.scannedFiles.push(...frameworkResult.scannedFiles);
    
    // 去重(与已存在连接比较)
    results.connections = this.deduplicate(results.connections, existingConnections);
    
    return results;
  }
  
  private deduplicate(
    scanned: ScannedConnection[], 
    existing: DbConnection[]
  ): ScannedConnection[] {
    return scanned.filter(conn => {
      // 检查 host+port 是否已存在
      return !existing.some(e => 
        e.host === conn.host && e.port === conn.port
      );
    });
  }
}

扫描流程

graph TD
    A["开始扫描"] --> B["EnvParser.scan()"]
    B --> C["DockerComposeParser.scan()"]
    C --> D["FrameworkParser.scan()"]
    
    D --> E["合并结果"]
    E --> F["去重处理"]
    F --> G["返回 ScannerResult"]
    
    B --> B1["查找 .env 文件"]
    B1 --> B2["解析 DATABASE_URL"]
    B2 --> B3["解析 DB_* 变量"]
    
    C --> C1["查找 docker-compose.yml"]
    C1 --> C2["识别 mysql/postgres 服务"]
    C2 --> C3["提取端口和环境变量"]
    
    D --> D1["查找框架配置文件"]
    D1 --> D2["解析连接数组"]

6.3 EnvParser 实现

.env 文件查找

const ENV_FILE_PATTERNS = [
  '.env',
  '.env.local',
  '.env.development',
  '.env.production',
  '.env.test',
  '.env.staging',
];

const MAX_DEPTH = 3;  // 最大搜索深度

private findEnvFiles(dir: string, depth: number): string[] {
  if (depth > MAX_DEPTH) return [];
  
  let entries: fs.Dirent[];
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return [];
  }
  
  const files: string[] = [];
  
  for (const entry of entries) {
    if (entry.isDirectory()) {
      // 跳过 node_modules, .git 等
      if (entry.name === 'node_modules' || 
          entry.name === '.git' || 
          entry.name.startsWith('.')) {
        continue;
      }
      files.push(...this.findEnvFiles(path.join(dir, entry.name), depth + 1));
    } else if (ENV_FILE_PATTERNS.includes(entry.name)) {
      files.push(path.join(dir, entry.name));
    }
  }
  
  return files;
}

.env 内容解析

private parseEnvContent(content: string): Record<string, string> {
  const vars: Record<string, string> = {};
  
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    
    // 跳过空行和注释
    if (!trimmed || trimmed.startsWith('#')) continue;
    
    // 解析 KEY=VALUE
    const eqIdx = trimmed.indexOf('=');
    if (eqIdx < 0) continue;
    
    const key = trimmed.substring(0, eqIdx).trim();
    let value = trimmed.substring(eqIdx + 1).trim();
    
    // 去除引号
    if ((value.startsWith('"') && value.endsWith('"')) ||
        (value.startsWith("'") && value.endsWith("'"))) {
      value = value.substring(1, value.length - 1);
    }
    
    vars[key] = value;
  }
  
  return vars;
}

连接信息提取

async parseEnvFile(filePath: string): Promise<ScannedConnection[]> {
  const content = fs.readFileSync(filePath, 'utf-8');
  const vars = this.parseEnvContent(content);
  const connections: ScannedConnection[] = [];
  
  // 1) DATABASE_URL 格式
  const urlKeys = ['DATABASE_URL', 'DB_URL', 'DATABASE_URI'];
  for (const key of urlKeys) {
    if (vars[key]) {
      const conn = this.parseConnectionString(vars[key], filePath);
      if (conn) connections.push(conn);
    }
  }
  
  // 2) DB_* 变量组
  const dbHost = vars['DB_HOST'];
  if (dbHost) {
    const port = vars['DB_PORT'] 
      ? parseInt(vars['DB_PORT'], 10) 
      : DEFAULT_PORTS.postgresql;
    connections.push(this.buildFromVars(
      dbHost, port, vars['DB_USER'] || '', vars['DB_PASSWORD'] || '',
      vars['DB_NAME'], 'postgresql', filePath, 'DB_*'
    ));
  }
  
  // 3) MYSQL_* 变量组
  const mysqlHost = vars['MYSQL_HOST'];
  if (mysqlHost) {
    const port = vars['MYSQL_PORT'] 
      ? parseInt(vars['MYSQL_PORT'], 10) 
      : DEFAULT_PORTS.mysql;
    connections.push(this.buildFromVars(
      mysqlHost, port, vars['MYSQL_USER'] || '', vars['MYSQL_PASSWORD'] || '',
      vars['MYSQL_DATABASE'], 'mysql', filePath, 'MYSQL_*'
    ));
  }
  
  // 4) PG_* 变量组
  const pgHost = vars['PG_HOST'];
  if (pgHost) {
    const port = vars['PG_PORT'] 
      ? parseInt(vars['PG_PORT'], 10) 
      : DEFAULT_PORTS.postgresql;
    connections.push(this.buildFromVars(
      pgHost, port, vars['PG_USER'] || '', vars['PG_PASSWORD'] || '',
      vars['PG_DATABASE'], 'postgresql', filePath, 'PG_*'
    ));
  }
  
  return connections;
}

DATABASE_URL 解析

parseConnectionString(url: string, sourceFile: string): ScannedConnection | null {
  try {
    // 判断数据库类型
    let type: 'mysql' | 'postgresql' | null = null;
    let normalizedUrl = url;
    
    if (url.startsWith('postgresql://') || url.startsWith('postgres://')) {
      type = 'postgresql';
    } else if (url.startsWith('mysql://') || url.startsWith('mysql2://')) {
      type = 'mysql';
      if (url.startsWith('mysql2://')) {
        normalizedUrl = 'mysql://' + url.substring('mysql2://'.length);
      }
    } else {
      return null;  // 不支持的协议
    }
    
    const parsed = new URL(normalizedUrl);
    
    return {
      name: `${type}:${parsed.hostname}:${parsed.port}`,
      type,
      host: parsed.hostname || 'localhost',
      port: parsed.port 
        ? parseInt(parsed.port, 10) 
        : DEFAULT_PORTS[type],
      username: decodeURIComponent(parsed.username || ''),
      password: decodeURIComponent(parsed.password || ''),
      database: parsed.pathname.startsWith('/')
        ? parsed.pathname.substring(1)
        : parsed.pathname,
      source: 'env',
      sourceFile,
      confidence: 'high',
    };
  } catch {
    return null;  // URL 解析失败
  }
}

DATABASE_URL 格式示例

# PostgreSQL
DATABASE_URL=postgresql://user:password@localhost:5432/mydb

# MySQL
DATABASE_URL=mysql://root:secret@127.0.0.1:3306/testdb

# MySQL (mysql2 driver)
DATABASE_URL=mysql2://user:pass@host:3306/db

6.4 DockerComposeParser 实现

docker-compose.yml 解析

export class DockerComposeParser {
  async scan(root: string): Promise<ScannerResult> {
    const result: ScannerResult = {
      connections: [],
      errors: [],
      scannedFiles: [],
    };
    
    const composeFiles = this.findComposeFiles(root);
    
    for (const file of composeFiles) {
      result.scannedFiles.push(file);
      
      try {
        const content = fs.readFileSync(file, 'utf-8');
        const config = yaml.parse(content);
        const connections = this.parseComposeConfig(config, file);
        result.connections.push(...connections);
      } catch (err: any) {
        result.errors.push({ file, error: err.message });
      }
    }
    
    return result;
  }
  
  private parseComposeConfig(
    config: any, 
    sourceFile: string
  ): ScannedConnection[] {
    const connections: ScannedConnection[] =();
    
    if (!config.services) return connections;
    
    for (const [name, service] of Object.entries(config.services)) {
      const conn = this.parseService(name, service as any, sourceFile);
      if (conn) connections.push(conn);
    }
    
    return connections;
  }
}

服务定义解析

private parseService(
  name: string, 
  service: any, 
  sourceFile: string
): ScannedConnection | null {
  const image = service.image || '';
  
  // 识别 MySQL/PostgreSQL 服务
  let type: 'mysql' | 'postgresql' | null = null;
  
  if (image.includes('mysql') || image.includes('mariadb')) {
    type = 'mysql';
  } else if (image.includes('postgres') || image.includes('postgresql')) {
    type = 'postgresql';
  } else {
    return null;
  }
  
  // 提取端口
  const ports = service.ports || [];
  let hostPort = DEFAULT_PORTS[type];
  
  for (const portDef of ports) {
    // 格式: "3306:3306" 或 "3306"
    const parts = portDef.toString().split(':');
    if (parts.length === 2) {
      hostPort = parseInt(parts[0], 10);
    }
  }
  
  // 提取环境变量
  const envVars = this.parseEnvVars(service.environment || {});
  const username = envVars['MYSQL_USER'] || 
                   envVars['POSTGRES_USER'] || 
                   'root';
  const password = envVars['MYSQL_PASSWORD'] || 
                   envVars['POSTGRES_PASSWORD'] || 
                   '';
  const database = envVars['MYSQL_DATABASE'] || 
                   envVars['POSTGRES_DB'];
  
  return {
    name: `${type}:${name}:${hostPort}`,
    type,
    host: 'localhost',
    port: hostPort,
    username,
    password,
    database,
    source: 'docker-compose',
    sourceFile,
    confidence: 'high',
  };
}

docker-compose.yml 示例

version: '3'
services:
  mysql:
    image: mysql:8.0
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: myapp
      MYSQL_USER: appuser
      MYSQL_PASSWORD: apppass
  
  postgres:
    image: postgres:15
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: pguser
      POSTGRES_PASSWORD: pgpass
      POSTGRES_DB: myapp

6.5 FrameworkParser 实现

支持的框架配置

graph TD
    A["FrameworkParser"] --> B["TypeORM"]
    A --> C["Laravel"]
    A --> D["Django"]
    A --> E["Prisma"]
    
    B --> B1["ormconfig.json"]
    B --> B2["data-source.ts"]
    
    C --> C1["config/database.php"]
    
    D --> D1["settings.py"]
    
    E --> E1["schema.prisma"]

TypeORM 配置解析

parseTypeOrmConfig(filePath: string): ScannedConnection[] {
  const content = fs.readFileSync(filePath, 'utf-8');
  const config = JSON.parse(content);
  const connections: ScannedConnection[] = [];
  
  // TypeORM 支持多连接配置
  const connectionConfigs = Array.isArray(config) ? config : [config];
  
  for (const conn of connectionConfigs) {
    if (conn.type === 'mysql' || conn.type === 'postgres') {
      connections.push({
        name: `${conn.type}:${conn.host || 'localhost'}:${conn.port}`,
        type: conn.type === 'postgres' ? 'postgresql' : 'mysql',
        host: conn.host || 'localhost',
        port: conn.port || DEFAULT_PORTS[conn.type === 'postgres' ? 'postgresql' : 'mysql'],
        username: conn.username || '',
        password: conn.password || '',
        database: conn.database,
        source: 'framework',
        sourceFile: filePath,
        confidence: 'high',
      });
    }
  }
  
  return connections;
}

ormconfig.json 示例

[
  {
    "type": "mysql",
    "host": "localhost",
    "port": 3306,
    "username": "root",
    "password": "secret",
    "database": "myapp"
  },
  {
    "type": "postgres",
    "host": "localhost",
    "port": 5432,
    "username": "pguser",
    "password": "pgpass",
    "database": "myapp"
  }
]

Prisma schema 解析

parsePrismaSchema(filePath: string): ScannedConnection[] {
  const content = fs.readFileSync(filePath, 'utf-8');
  const connections: ScannedConnection[] = [];
  
  // 提取 datasource url
  const urlMatch = content.match(/url\s*=\s*"([^"]+)"/);
  if (urlMatch) {
    const url = urlMatch[1];
    const conn = this.parseConnectionString(url, filePath);
    if (conn) connections.push(conn);
  }
  
  // 也支持 env() 格式
  const envMatch = content.match(/url\s*=\s*env\("([^"]+)"\)/);
  if (envMatch) {
    // 标记为需要额外解析环境变量
    connections.push({
      name: `prisma:env:${envMatch[1]}`,
      type: 'postgresql',  // Prisma 默认 PostgreSQL
      host: '',
      port: 0,
      username: '',
      password: '',
      source: 'framework',
      sourceFile: filePath,
      confidence: 'low',
    });
  }
  
  return connections;
}

schema.prisma 示例

datasource db {
  provider = "postgresql"
  url      = "postgresql://user:pass@localhost:5432/mydb"
}

generator client {
  provider = "prisma-client-js"
}

6.6 去重与合并策略

去重逻辑

private deduplicate(
  scanned: ScannedConnection[], 
  existing: DbConnection[]
): ScannedConnection[] {
  return scanned.filter(conn => {
    // 按 host+port 去重
    const existsByHostPort = existing.some(e => 
      e.host === conn.host && e.port === conn.port
    );
    
    // 按数据库文件内的重复也去重
    // 同一文件可能定义多个指向同一数据库的连接
    
    return !existsByHostPort;
  });
}

多源合并示意

graph TD
    subgraph "扫描结果"
        A[".env: mysql:localhost:3306"]
        B["docker-compose: mysql:localhost:3306"]
        C["ormconfig: mysql:localhost:3306"]
        D["docker-compose: postgres:localhost:5432"]
    end
    
    A --> E["合并去重"]
    B --> E
    C --> E
    D --> E
    
    E --> F["最终结果"]
    F --> G["mysql:localhost:3306"]
    F --> H["postgres:localhost:5432"]

6.7 置信度评估

置信度分级

置信度条件示例
high信息完整,可直接使用DATABASE_URL, docker-compose 服务
medium信息基本完整,可能缺密码框架配置
low信息不完整,需用户补充部分 DB_* 变量,env() 引用

置信度使用

// 在 UI 中展示置信度
const items = result.connections.map(conn => ({
  label: conn.name,
  description: `${conn.type}://${conn.host}:${conn.port}`,
  detail: `Source: ${conn.source} (${conn.confidence} confidence)`,
  connection: conn,
}));

// 根据置信度决定是否需要用户输入
if (conn.confidence === 'low') {
  // 提示用户补充缺失信息
  const password = await vscode.window.showInputBox({
    prompt: `Enter password for ${conn.name}`,
    password: true,
  });
}

6.8 自动扫描时机

激活时扫描

// extension.ts
export function activate(context: vscode.ExtensionContext) {
  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri?.fsPath;
  if (workspaceRoot) {
    autoScanProject(workspaceRoot);
  }
}

async function autoScanProject(workspaceRoot: string) {
  const existingConnections = await connectionManager!.listConnections();
  const result = await scannerEngine!.scan(workspaceRoot, existingConnections);
  
  if (result.connections.length > 0) {
    // 发现新连接,提示用户添加
    const selected = await vscode.window.showQuickPick(items, {
      placeHolder: `Found ${result.connections.length} connection(s). Select to add:`,
      canPickMany: true,
    });
    
    if (selected) {
      for (const item of selected) {
        await connectionManager!.addConnection({
          ...item.connection,
          scope: 'project',
        });
      }
      treeProvider?.refresh();
    }
  }
}

手动扫描命令

const scanProjectCmd = vscode.commands.registerCommand('vsdb.scanProject', async () => {
  const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri?.fsPath;
  if (!workspaceRoot) {
    vscode.window.showWarningMessage('No workspace folder open.');
    return;
  }
  
  vscode.window.withProgress({
    location: vscode.ProgressLocation.Notification,
    title: 'VSDB: Scanning project for database connections...',
  }, async () => {
    const existingConnections = await connectionManager!.listConnections();
    const result = await scannerEngine!.scan(workspaceRoot, existingConnections);
    
    // 处理结果...
  });
});

6.9 错误处理

解析错误收集

interface ScannerResult {
  connections: ScannedConnection[];
  errors: Array<{ file: string; error: string }>;  // 收集但不中断
  scannedFiles: string[];
}

错误示例处理

try {
  const config = yaml.parse(content);
  const connections = this.parseComposeConfig(config, file);
  result.connections.push(...connections);
} catch (err: any) {
  // 收集错误,继续扫描其他文件
  result.errors.push({ file, error: err.message });
}

错误报告

// 扫描完成后报告错误
if (result.errors.length > 0) {
  console.warn('VSDB scan errors:', result.errors);
  // 可选:向用户展示错误摘要
}

6.10 小结

本章介绍了 VSDB 的扫描引擎设计:

Parser目标解析方式置信度
EnvParser.env 文件DATABASE_URL + DB_* 变量
DockerComposeParserdocker-compose.yml服务 image + ports + env
FrameworkParser框架配置文件JSON/YAML/自定义解析中~高

关键设计:

  • 协调器模式:ScannerEngine 协调多个 Parser
  • 去重合并:host+port 去重避免重复添加
  • 置信度评估:区分可直接使用 vs 需补充信息
  • 错误收集:解析失败不中断,收集报告
  • 自动触发:激活时自动扫描,也可手动触发

下一章将介绍 UI 组件的实现。