第六章:扫描引擎设计
本章介绍 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-compose | docker-compose.yml | 中 | 高(服务定义) |
| TypeORM | ormconfig.json, data-source.ts | 中 | 高 |
| Laravel | config/database.php | 高 | 中 |
| Django | settings.py | 高 | 中 |
| Prisma | schema.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_* 变量 | 高 |
| DockerComposeParser | docker-compose.yml | 服务 image + ports + env | 高 |
| FrameworkParser | 框架配置文件 | JSON/YAML/自定义解析 | 中~高 |
关键设计:
- 协调器模式:ScannerEngine 协调多个 Parser
- 去重合并:host+port 去重避免重复添加
- 置信度评估:区分可直接使用 vs 需补充信息
- 错误收集:解析失败不中断,收集报告
- 自动触发:激活时自动扫描,也可手动触发
下一章将介绍 UI 组件的实现。