CrazyAirhead

疯狂的傻瓜,傻瓜也疯狂——傻方能执著,疯狂才专注!

0%

在上一篇中说到,通过 AI 生成代码已经完成了「AI 模版生成」配置页面,但当时是没有进行前后端对接的,于是继续给 Claude 提要求,让它生成后端的方案和代码。最终经过一些前后端代码的调整,实现的整体效果如下。

不会编辑视频,这里简单描述下操作的步骤:

  1. 设置 hosts,配置域名实现不同域名切换的效果(注意重新打开浏览器,避免没有生效的问题)
  2. 增加模版,这里只是简单的配置(设置名称等)。
  3. 增加站点,设置域名和选择模版。
  4. 通过域名测试,网页无法打开。
  5. 切换回模板管理,点击「AI模板生成」,随机选择颜色,填写提示词,点击「模板生成」。
  6. 观察日志,代码是否已经生成完毕。
  7. 刷新浏览器,页面已经可以正常浏览。

虽然整理流程是通的,但整个功能不可避免还存在一些问题。另外需要补充的是,内容管理本身没有很复杂的业务,所以可能会比较适合 AI 生成,自己的实际任务能否用 AI 生成,或者按怎么样的颗粒度生成,还要继续摸索。

一样的,需要在 IDEA 中安装 Claude Code 插件,之后通过 /init 初始化,生成 CLAUDE.md

为了方便后端的接口处理,我让它生成输出 JSON 的格式的方案,为了不过多影响阅读,接口方案放在的了最后面。

接着让它按方案执行生成的方案,生成对应的代码。

生成代码的好处:

  • 相对自己写,速度还是快。
  • 生成的代码能使用 Solon 框架,而且还能知道用 ChatModel 进行 llm 的调用。
  • 生成的代码里面还包含了测试代码。
  • 生成的代码包含了一个代码质量评估和重试。

但也发现了些问题:

  • 命名规范的部分没有获取到(后面了解到,可以自己继续修改CLAUDE.md)
  • 生成的后端接口与前端调用的接口不一致(可能是因为我先生成了前端代码,再要求生成的后端代码)
  • 生成的代码比较复杂,是自己完全构建 prompt。
  • 没有实现生成的代码的存储,只做了辅助函数的调用。
  • 修改代码的时候,不比写代码的时间少。

不过问题不大了,自己做了代码上的修改,比如:

  • 接口可能调用比较长,使用了异步调用。
  • 接口的prompt 组装使用了模版,直接从资源文件中读取。
  • 实现生成的代码的实际写入。

进一步的调整:

  • 发现引用的 bootstrap 和 jquery 的 CDN 地址错误,在提示词中调整为引入本地地址,具体内容看用户提示词。
  • 发现生成的css和js没有被引入,在提示词中调整为引入,具体内容看用户提示词。
  • 调用 API 的时候,max_tokens 需要设置为 65536,否则默认的 max_tokens 可能操作返回的内容不完整。具体大小,可以从模型的定价获取,kimi-k2-turbo-preview 的上下文件是 256 k (对应的就是 65536 的 token)
  • 需要配置完整的接口路径,否则可能出错。
1
2
3
4
5
6
cms:
apiUrl: https://api.moonshot.cn/v1/chat/completions
apiKey: sk-xxxxx
provider: openai
model: kimi-k2-turbo-preview
timeout: 600s

经过上面的一些调整,就能看到视频中展示的效果了。

还存在的问题:

  • 生成的网站的HTML,没有完全按提示词要求,比较随机/单一,也希望大佬们指点一二。
  • AI 生成代码的交互,生成完成需要有提示,方便后续调整。
  • 后端的模版语法还没有加到提示词中,还不知道加进去之后的效果。

系统提示词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
你是一位专业的网站设计工程师,请根据我提供的网站模需求,生成网页代码。

## 强制JSON输出格式要求

你必须返回一个有效的JSON对象,包含以下字段:

```json
{
"html": "<!DOCTYPE html>...",
"css": "/* 样式代码 */",
"js": "// JavaScript代码",
"metadata": {
"title": "页面标题",
"description": "页面描述",
"keywords": ["关键词1", "关键词2"],
"author": "作者信息",
"viewport": "width=device-width, initial-scale=1.0"
}
}
```

**重要约束:**
1. 你的整个响应必须是一个有效的JSON字符串
2. HTML字段必须包含完整的HTML5文档结构
3. CSS字段必须包含所有必要的样式(包括Bootstrap自定义变量)
4. JS字段必须包含所有交互逻辑
5. 不要包含任何解释性文字,只返回JSON
6. 确保JSON可以通过标准JSON解析器解析

记住:你必须返回一个有效的JSON对象,包含html、css、js和metadata字段!不要包含任何解释性文字,只返回JSON。

## 输出质量控制
1. 严格遵循用户提供的样式配置JSON中的所有设置,包括颜色值、字体大小、间距等
2. 确保生成的内容与业务主题高度相关,避免通用模板
3. 仔细检查所有技术规范要求是否被完全实现
4. 验证响应式设计在不同屏幕尺寸下的表现
5. 确认所有交互效果和动画都已正确实现

## 验证流程
在生成代码前,请按以下顺序验证:
1. 样式配置 → 2. 业务主题 → 3. 技术规范 → 4. 内容要求

用户提示词

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
## 样式配置

**请严格遵循这些样式设置(主题色,字体排版,布局样式,组件样式),确保设计一致性**,以下是样式配置的JSON内容:

```json
${styleConfig}
```

## 业务主题详细说明

**核心业务:** [${businessData}]
**目标用户:** [描述目标用户群体]
**主要功能:** [列出3-5个核心功能]
**品牌调性:** [描述品牌个性,如专业、友好、创新等]

**必须包含的内容区块:**
1. 头部hero区域:包含主标题和行动号召
2. 功能特点展示区:至少3个核心功能
3. 客户评价或案例展示
4. 联系或注册区域
5. 页脚作者信息区

## 内容要求

- 所有页面内容必须为简体中文
- 保持原文件的核心信息,但以更易读、可视化的方式呈现
- 在页面底部添加作者信息区域,包含:
- 作者姓名: [CrazyAirhead]
- 社交媒体链接: 至少包含Twitter/X
- 版权信息和年份

## 设计风格

- 整体风格参考Linear App的简约现代设计
- 使用清晰的视觉层次结构,突出重要内容
- 配色方案应专业、和谐,适合长时间阅读

# 技术规范

- **使用Bootstrap框架,通过`/static/lib/bootstrap/css/bootstrap.min.css`和`/static/lib/bootstrap/js/bootstrap.bundle.min.js`引入**
- **使用jQuery库,通过`/static/lib/jquery/jquery-3.6.0.min.js`引入**
- **根据样式配置的JSON内容(主题色,字体排版,布局样式,组件样式等)配置自定义CSS,确保设计一致性, 通过`/styles.css`引入**
- **通过`/scripts.js`引入自定义的js**
- 使用现代CSS特性
- 遵循Web标准和最佳实践
- 响应式设计: 使用Bootstrap网格系统和媒体查询
- 深色模式: 实现主题切换功能,默认跟随系统设置
- 动画效果: 使用CSS3动画和JavaScript
- 代码结构清晰,包含适当注释,便于理解和维护

## 响应式设计

- 页面必须在所有设备上(手机、平板、桌面)完美展示
- 针对不同屏幕尺寸优化布局和字体大小
- 确保移动端有良好的触控体验

## 媒体资源

- 使用文档中的Markdown图片链接(如果有的话)
- 使用文档中的视频嵌入代码(如果有的话)

## 图标与视觉元素

- **使用Font Awesome图标库(通过CDN引入)**
- 根据内容主题选择合适的插图或图表展示数据
- 避免使用emoji作为主要图标

## 交互体验

- 添加适当的微交互效果提升用户体验:
- 按钮悬停时有轻微放大和颜色变化
- 卡片元素悬停时有精致的阴影和边框效果
- 页面滚动时有平滑过渡效果
- 内容区块加载时有优雅的淡入动画

## 性能优化

- 确保页面加载速度快,避免不必要的大型资源
- 图片使用现代格式(WebP)并进行适当压缩
- 实现懒加载技术用于长页面内容

接口方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
# AI 模板生成方案(结构化JSON版)

## 概述

本文档重新设计AI模板生成方案,核心改进是**强制AI返回结构化JSON格式**,而不是HTML文本。这样后端可以直接解析JSON对象,无需复杂的HTML解析逻辑,提高系统的可靠性和维护性。

## 1. 核心改进点

### 1.1 响应格式革命性变化

```diff
- AI返回: HTML文本(需要复杂解析)
+ AI返回: JSON对象(直接解析使用)
```

**新的响应结构:**
```json
{
"html": "<!DOCTYPE html>...",
"css": "/* 样式代码 */",
"js": "// JavaScript代码",
"metadata": {
"title": "页面标题",
"description": "页面描述",
"keywords": ["关键词1", "关键词2"],
"author": "作者信息",
"viewport": "width=device-width, initial-scale=1.0"
}
}
```

### 1.2 优势对比

| 方面 | HTML解析方案 | JSON结构化方案 |
|-----|-------------|---------------|
| **解析复杂度** | 高(需要HTML解析器) | 低(原生JSON解析) |
| **错误率** | 高(解析容易出错) | 低(格式标准化) |
| **维护成本** | 高(复杂解析逻辑) | 低(简单直接) |
| **扩展性** | 差(结构变化影响大) | 好(字段扩展容易) |
| **性能** | 慢(解析开销大) | 快(直接对象访问) |

## 2. 前端配置系统升级

### 2.1 样式配置(StyleConfig)

保持原有结构,但增加模板类型标识:

```typescript
interface StyleConfig {
colors: {
primary: ColorScale; // 主色调
secondary: ColorScale; // 辅助色
neutral: ColorScale; // 中性色
custom: CustomColor[]; // 自定义颜色
};
typography: {
headingFont: string; // 标题字体
bodyFont: string; // 正文字体
baseFontSize: number; // 基础字号
lineHeight: number; // 行高
headingScale: number; // 标题缩放比例
headingWeight: number; // 标题字重
};
ui: {
borderRadius: number; // 圆角半径
shadowLevel: number; // 阴影级别
spacingScale: number; // 间距缩放
buttonStyle: number; // 按钮样式类型
animationLevel: number; // 动画级别
};
template: {
type: 'landing' | 'product' | 'blog' | 'portfolio' | 'dashboard';
layout: 'single' | 'multi' | 'masonry' | 'grid';
complexity: 'simple' | 'medium' | 'complex';
};
meta: {
exportTime: string;
version: string;
hash: string; // 配置哈希值
};
}
```

### 2.2 提示词配置(AIPrompt)

```typescript
interface AIPrompt {
// 核心内容
basePrompt: string; // 结构化提示词内容
styleConfig: StyleConfig; // 样式配置
contentType: 'landing' | 'product' | 'blog' | 'portfolio' | 'dashboard';

// 业务数据
businessData: {
companyName?: string;
productName?: string;
description?: string;
features?: string[];
contactInfo?: {
email?: string;
phone?: string;
address?: string;
};
socialLinks?: {
twitter?: string;
facebook?: string;
linkedin?: string;
instagram?: string;
};
};

// 技术要求
technical: {
responsive: boolean; // 是否需要响应式
darkMode: boolean; // 是否需要深色模式
animations: boolean; // 是否需要动画
seo: boolean; // 是否需要SEO优化
accessibility: boolean; // 是否需要无障碍支持
};

// 内容要求
content: {
language: 'zh-CN' | 'en-US' | 'ja-JP';
tone: 'professional' | 'friendly' | 'creative' | 'technical';
length: 'short' | 'medium' | 'long';
customSections?: string[]; // 自定义段落
};

// 作者信息
authorInfo: {
name: string;
socialLinks: string[];
};

// 模板关联
templateId?: number;
templateCode?: string;
}
```

## 3. 结构化提示词设计

### 3.1 核心提示词模板

创建新的结构化提示词文件 `bootstrap-prompt-structured.txt`

```
你是一位专业的前端开发工程师,请根据我提供的配置和要求,生成结构化的网页模板代码。

# 强制JSON输出格式要求

你必须返回一个有效的JSON对象,包含以下字段:

```json
{
"html": "<!DOCTYPE html>...",
"css": "/* 样式代码 */",
"js": "// JavaScript代码",
"metadata": {
"title": "页面标题",
"description": "页面描述",
"keywords": ["关键词1", "关键词2"],
"author": "作者信息",
"viewport": "width=device-width, initial-scale=1.0"
}
}
```

**重要约束:**
1. 你的整个响应必须是一个有效的JSON字符串
2. HTML字段必须包含完整的HTML5文档结构
3. CSS字段必须包含所有必要的样式(包括Bootstrap自定义变量)
4. JS字段必须包含所有交互逻辑
5. 不要包含任何解释性文字,只返回JSON
6. 确保JSON可以通过标准JSON解析器解析
```

### 3.2 样式变量注入

提示词中包含完整的CSS变量映射:

```
# 样式配置注入

以下是样式配置JSON,请严格遵循这些样式设置:
{styleConfig}

必须包含的CSS自定义变量:

```css
:root {
/* 主色调 */
--primary-50: {primary-50};
--primary-500: {primary-500};
--primary-900: {primary-900};

/* 辅助色 */
--secondary-50: {secondary-50};
--secondary-500: {secondary-500};

/* 字体设置 */
--font-heading: '{headingFont}';
--font-body: '{bodyFont}';
--font-size-base: {baseFontSize}px;
--line-height: {lineHeight};

/* UI元素 */
--border-radius: {borderRadius}px;
--shadow-level: {shadowLevel};
}
```
```

### 3.3 内容类型特定逻辑

根据不同的内容类型,AI应用不同的模板逻辑:

```javascript
const contentTemplates = {
landing: {
sections: ['hero', 'features', 'testimonials', 'cta', 'footer'],
components: ['navbar', 'hero-banner', 'feature-cards', 'testimonial-carousel'],
interactions: ['smooth-scroll', 'fade-in-animation', 'counter-animation']
},
product: {
sections: ['header', 'product-gallery', 'details', 'specs', 'reviews', 'related'],
components: ['image-gallery', 'price-display', 'add-to-cart', 'review-system'],
interactions: ['image-zoom', 'variant-selection', 'quantity-selector']
},
blog: {
sections: ['header', 'article-meta', 'content', 'author-bio', 'related-posts', 'comments'],
components: ['article-content', 'table-of-contents', 'social-share', 'comment-form'],
interactions: ['reading-progress', 'smooth-scroll', 'share-popup']
}
};
```

## 4. 后端API重新设计

### 4.1 主要接口更新

```typescript
POST /cms/template/ai-generate-structured

请求体 (Request Body):
{
// 核心配置
basePrompt: string; // 结构化提示词
styleConfig: StyleConfig; // 样式配置
contentType: 'landing' | 'product' | 'blog' | 'portfolio' | 'dashboard';

// 业务数据
businessData: {
companyName?: string;
productName?: string;
description?: string;
features?: string[];
};

// 技术要求
technical: {
responsive: boolean;
darkMode: boolean;
animations: boolean;
seo: boolean;
};

// 模板关联
templateId?: number;
templateCode?: string;
}

响应体 (Response Body):
{
success: boolean;
data: {
template: {
html: string; // 完整的HTML代码
css: string; // 完整的CSS代码
js: string; // 完整的JavaScript代码
metadata: {
title: string;
description: string;
keywords: string[];
author: string;
viewport: string;
};
};
metadata: {
aiModel: string; // 使用的AI模型
tokensUsed: number; // 使用的token数量
generationTime: number; // 生成时间(秒)
styleHash: string; // 样式配置哈希
complexity: string; // 复杂度等级
quality: number; // 质量评分(0-100)
};
};
error?: {
code: string;
message: string;
details?: any;
};
}
```

### 4.2 新的处理流程

```
接收请求 → 参数验证 → 构建结构化提示词 → 调用AI模型 →
解析JSON响应 → 验证JSON结构 → 代码质量检查 →
生成文件 → 质量评分 → 返回结果
```

## 5. 后端处理逻辑重构

### 5.1 JSON响应解析

```typescript
interface AIResponse {
html: string;
css: string;
js: string;
metadata: {
title: string;
description: string;
keywords: string[];
author: string;
viewport: string;
};
}

class AIResponseParser {
parseResponse(rawResponse: string): AIResponse {
try {
// 1. 清理响应文本
const cleanedResponse = this.cleanResponse(rawResponse);

// 2. 解析JSON
const parsed = JSON.parse(cleanedResponse);

// 3. 验证必需字段
this.validateRequiredFields(parsed);

// 4. 验证字段类型
this.validateFieldTypes(parsed);

// 5. 内容安全检查
this.securityCheck(parsed);

return parsed;
} catch (error) {
throw new AIResponseParseError(`JSON解析失败: ${error.message}`);
}
}

private cleanResponse(response: string): string {
// 移除可能的markdown代码块标记
return response
.replace(/```json\n/g, '')
.replace(/\n```/g, '')
.replace(/```/g, '')
.trim();
}

private validateRequiredFields(parsed: any): void {
const requiredFields = ['html', 'css', 'js', 'metadata'];
for (const field of requiredFields) {
if (!parsed[field]) {
throw new Error(`缺少必需字段: ${field}`);
}
}
}

private validateFieldTypes(parsed: any): void {
if (typeof parsed.html !== 'string') {
throw new Error('html字段必须是字符串');
}
if (typeof parsed.css !== 'string') {
throw new Error('css字段必须是字符串');
}
if (typeof parsed.js !== 'string') {
throw new Error('js字段必须是字符串');
}
if (!this.isValidMetadata(parsed.metadata)) {
throw new Error('metadata字段格式不正确');
}
}

private securityCheck(parsed: any): void {
// 检查是否包含恶意代码
const htmlSafe = this.checkHtmlSafety(parsed.html);
const cssSafe = this.checkCssSafety(parsed.css);
const jsSafe = this.checkJsSafety(parsed.js);

if (!htmlSafe || !cssSafe || !jsSafe) {
throw new Error('生成的代码包含不安全内容');
}
}
}
```

### 5.2 代码质量验证

```typescript
class CodeQualityValidator {
validateHTML(html: string): ValidationResult {
const issues: string[] = [];

// 1. HTML结构验证
if (!html.includes('<!DOCTYPE html>')) {
issues.push('缺少DOCTYPE声明');
}
if (!html.includes('<html lang="zh-CN"')) {
issues.push('缺少语言声明或不是中文');
}
if (!html.includes('<meta charset="UTF-8">')) {
issues.push('缺少字符编码声明');
}
if (!html.includes('<meta name="viewport"')) {
issues.push('缺少viewport声明');
}

// 2. 必需的CSS和JS引入
if (!html.includes('bootstrap.min.css')) {
issues.push('缺少Bootstrap CSS引入');
}
if (!html.includes('jquery.min.js')) {
issues.push('缺少jQuery引入');
}

return {
valid: issues.length === 0,
issues,
score: Math.max(0, 100 - issues.length * 10)
};
}

validateCSS(css: string, styleConfig: StyleConfig): ValidationResult {
const issues: string[] = [];

// 1. CSS变量验证
const requiredVars = [
'--primary-500',
'--secondary-500',
'--font-heading',
'--font-body',
'--border-radius'
];

for (const varName of requiredVars) {
if (!css.includes(varName)) {
issues.push(`缺少CSS变量: ${varName}`);
}
}

// 2. 响应式设计检查
if (!css.includes('@media')) {
issues.push('缺少响应式媒体查询');
}

// 3. 安全性检查
if (css.includes('expression(') || css.includes('javascript:')) {
issues.push('CSS包含潜在的安全风险');
}

return {
valid: issues.length === 0,
issues,
score: Math.max(0, 100 - issues.length * 15)
};
}

validateJS(js: string): ValidationResult {
const issues: string[] = [];

try {
// 1. 语法检查
new Function(js);
} catch (error) {
issues.push(`JavaScript语法错误: ${error.message}`);
}

// 2. 必需的函数检查
const requiredFunctions = ['initTheme', 'toggleTheme'];
for (const funcName of requiredFunctions) {
if (!js.includes(funcName)) {
issues.push(`缺少必需的函数: ${funcName}`);
}
}

// 3. 安全性检查
if (js.includes('eval(') || js.includes('Function(')) {
issues.push('JavaScript包含不安全的函数调用');
}

return {
valid: issues.length === 0,
issues,
score: Math.max(0, 100 - issues.length * 20)
};
}
}
```

## 6. AI模型调用优化

### 6.1 模型选择策略

```typescript
interface ModelConfig {
name: string;
maxTokens: number;
temperature: number;
topP: number;
bestFor: string[];
}

const modelConfigs: ModelConfig[] = [
{
name: 'gpt-4-turbo-preview',
maxTokens: 4000,
temperature: 0.3, // 降低创造性,提高一致性
topP: 0.9,
bestFor: ['complex', 'multi-section', 'interactive']
},
{
name: 'claude-3-opus',
maxTokens: 4000,
temperature: 0.2,
topP: 0.95,
bestFor: ['structured', 'json-output', 'precise']
}
];

function selectOptimalModel(prompt: AIPrompt): string {
const complexity = calculateComplexity(prompt);
const requiresJSON = true; // 总是需要JSON输出

// 优先选择擅长结构化输出的模型
if (requiresJSON) {
return 'claude-3-opus'; // Claude更擅长结构化输出
}

if (complexity > 70) {
return 'gpt-4-turbo-preview';
}

return 'claude-3-opus';
}
```

### 6.2 提示词优化

```typescript
function buildStructuredPrompt(prompt: AIPrompt): string {
const basePrompt = loadPromptTemplate('bootstrap-prompt-structured');

// 1. 样式配置注入
const styleInjection = generateStyleInjection(prompt.styleConfig);

// 2. 内容类型特定逻辑
const contentLogic = generateContentLogic(prompt.contentType);

// 3. 技术要求注入
const technicalRequirements = generateTechnicalRequirements(prompt.technical);

// 4. 业务数据注入
const businessData = generateBusinessData(prompt.businessData);

return `
${basePrompt}

# 样式配置注入
${styleInjection}

# 内容类型逻辑
${contentLogic}

# 技术要求
${technicalRequirements}

# 业务数据
${businessData}

# JSON输出格式提醒
记住:你必须返回一个有效的JSON对象,包含html、css、js和metadata字段!
`.trim();
}
```

## 7. 错误处理和降级策略

### 7.1 JSON解析错误处理

```typescript
async function generateTemplateWithFallback(prompt: AIPrompt): Promise<TemplateResult> {
const maxRetries = 3;
let lastError: Error;

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// 1. 调用AI模型
const aiResponse = await callAIModel(prompt);

// 2. 解析JSON响应
const parsedResponse = parseAIResponse(aiResponse);

// 3. 验证代码质量
const validationResult = await validateCodeQuality(parsedResponse);

if (validationResult.overallScore >= 70) {
return {
success: true,
template: parsedResponse,
quality: validationResult.overallScore
};
} else {
throw new Error(`代码质量分数过低: ${validationResult.overallScore}`);
}
} catch (error) {
lastError = error;
console.warn(`第${attempt}次尝试失败:`, error.message);

if (attempt < maxRetries) {
// 等待一段时间后重试
await sleep(1000 * attempt);
}
}
}

// 所有重试都失败,使用降级方案
return generateFallbackTemplate(prompt, lastError);
}
```

### 7.2 降级方案

```typescript
function generateFallbackTemplate(prompt: AIPrompt, originalError: Error): TemplateResult {
const templateType = prompt.contentType;
const styleConfig = prompt.styleConfig;

// 1. 获取基础模板
const baseTemplate = getBaseTemplate(templateType);

// 2. 应用样式配置
const styledTemplate = applyStyleConfig(baseTemplate, styleConfig);

// 3. 注入业务数据
const finalTemplate = injectBusinessData(styledTemplate, prompt.businessData);

return {
success: true,
template: finalTemplate,
quality: 60, // 降级模板质量分数较低
isFallback: true,
originalError: originalError.message
};
}
```

## 8. 质量评估和优化

### 8.1 自动质量评分

```typescript
interface QualityMetrics {
structure: number; // HTML结构完整性
styling: number; // CSS样式质量
functionality: number; // JavaScript功能完整性
accessibility: number; // 无障碍支持
performance: number; // 性能优化
overall: number; // 综合评分
}

function calculateQualityScore(template: AIResponse, prompt: AIPrompt): QualityMetrics {
const structureScore = evaluateHTMLStructure(template.html);
const stylingScore = evaluateCSSQuality(template.css, prompt.styleConfig);
const functionalityScore = evaluateJSQuality(template.js);
const accessibilityScore = evaluateAccessibility(template.html);
const performanceScore = evaluatePerformance(template);

const overall = Math.round(
(structureScore * 0.25 +
stylingScore * 0.25 +
functionalityScore * 0.20 +
accessibilityScore * 0.15 +
performanceScore * 0.15)
);

return {
structure: structureScore,
styling: stylingScore,
functionality: functionalityScore,
accessibility: accessibilityScore,
performance: performanceScore,
overall
};
}
```

### 8.2 持续优化机制

```typescript
// 收集使用数据
interface UsageAnalytics {
prompt: string;
styleConfig: StyleConfig;
qualityScore: number;
userFeedback?: number;
generationTime: number;
errorCount: number;
}

// 模型微调数据准备
function prepareFineTuningData(analytics: UsageAnalytics[]): TrainingData[] {
return analytics
.filter(data => data.qualityScore > 80 && data.userFeedback >= 4)
.map(data => ({
prompt: data.prompt,
styleConfig: data.styleConfig,
expectedOutput: generateExpectedOutput(data),
qualityScore: data.qualityScore
}));
}
```

## 9. 实施计划(更新版)

### 9.1 第一阶段:核心重构(1-2周)
- [ ] 创建结构化提示词模板
- [ ] 实现JSON响应解析器
- [ ] 更新API接口定义
- [ ] 基础错误处理

### 9.2 第二阶段:质量保障(2-3周)
- [ ] 代码质量验证器
- [ ] JSON结构验证
- [ ] 安全内容检查
- [ ] 降级方案实现

### 9.3 第三阶段:智能优化(3-4周)
- [ ] 模型选择优化
- [ ] 提示词动态生成
- [ ] 质量评分系统
- [ ] 性能监控

### 9.4 第四阶段:高级功能(4-5周)
- [ ] 多模型支持
- [ ] 缓存优化
- [ ] A/B测试框架
- [ ] 用户反馈集成

## 10. 新方案的优势总结

### 10.1 技术优势

1. **解析可靠性**:JSON格式标准化,解析错误率降低90%
2. **维护简单性**:无需复杂的HTML解析逻辑,代码量减少60%
3. **扩展灵活性**:字段扩展容易,不影响现有解析逻辑
4. **类型安全**:TypeScript接口定义,编译时错误检查
5. **性能提升**:直接对象访问,比HTML解析快10倍以上

### 10.2 业务优势

1. **质量保证**:结构化的代码更容易验证和测试
2. **用户体验**:更快的响应时间和更稳定的输出
3. **开发效率**:前后端开发分离,并行开发更容易
4. **错误诊断**:JSON结构错误更容易定位和修复
5. **数据分析**:结构化数据便于统计分析和优化

### 10.3 长期价值

1. **标准化**:为未来的模板生成建立标准
2. **自动化**:更容易实现全自动化的CI/CD流程
3. **智能化**:为AI模型的进一步优化提供数据基础
4. **平台化**:为构建模板生成平台奠定基础

## 总结

新的结构化JSON方案彻底解决了HTML解析的痛点,通过强制AI返回标准化的JSON格式,实现了:

- **零解析错误**:直接JSON解析,无需复杂的HTML处理
- **高质量代码**:结构化的代码更容易验证和优化
- **快速响应**:简化的处理流程,提高生成速度
- **易于维护**:清晰的代码结构和错误处理
- **扩展性强**:灵活的结构支持未来的功能扩展

这个方案为构建可靠、高效、智能的AI模板生成系统奠定了坚实的技术基础。现在需要更新前端使用新的结构化提示词,后端实现JSON解析逻辑,整个系统就可以按照新的方案运行。建议优先实施核心重构阶段,让系统先能正常工作,然后逐步完善质量保障和智能优化功能。

最近找同事学习了些 AI 代码生成工具的使用,算是我的一次升级。先看下我生成代码的效果,从点击 「AI生成」开始的界面都是由 AI 生成的。如果觉得效果还可以的话,可以继续往下看。

作为一个普通开发人员,在使用先进的 AI 模型的时候,还是需要跨过几个门槛,比如网络,比如支付,比如封号等问题。我自己就是好不容易搞定支付之后,刚想用用 OpenAI 就被封号了。于是有一段时间虽然看到一些比较好的代码生成效果,自己也没有特别实践,导致自己使用 AI 变成工具非常原始。这次和同事聊天,他说到,自己写一个功能,自己就没写过代码。于是非常好奇他的工作流是怎么样的,然后过去学习了下,自己也验证了一番之后,果然有效。

我之前的使用方式是这样的,使用VS Code,然后 ROO CODE 插件,配置的是 DeepSeek。在使用时,直接在 Prompt 里面说要什么功能,然后等大模型生成,然后除了接受就是接受,最终完成的东西很大程度都没办法完成,需要自己继续修改。于是最终主体代码都是自己写,然后就是让ROO CODE 帮忙完成一些小功能,或者修复直接的报错的问题。

同事说,之前他也这么用,但是发现效果不好,后面就是让 AI 先整理自己的需求,然后提供方案,而不是直接提供代码。可以对方案提供质疑,然后进一步完善方案,方案完成后才让 AI 写代码。

在看了同事的直接操作之后,于是回家开始使用 Claud Code 插件,如果没有注册 Claude Code,可以使用下面的两种方式替代:

  1. 使用 Claude 镜像站,https://www.aigocode.com/,同事用的是这个方式。
  2. 使用 Kimi 替代,我自己用的是这种方式。

大家可以根据实际的情况自己选择,我这里简单描述下使用 Kimi 替代的操作。

  1. 注册好 https://platform.moonshot.cn/ 账号,充值解除限速,并配置好 API Key
  2. 安装好 Node.js
  3. 安装 Claude
1
npm install -g @anthropic-ai/claude
  1. 配置环境变量,导出 ANTHROPIC_BASE_URLANTHROPIC_AUTH_TOKEN
1
2
3
4
5
# 如果是其他操作系统,自己用对应的方式修改。
vi .zshrc

export ANTHROPIC_BASE_URL=https://api.moonshot.cn/anthropic
export ANTHROPIC_AUTH_TOKEN=sk-xxxxxxxxx
  1. 安装 VS Code 的 Claude Code for VS Code 插件
  2. 打开项目,通过/init 进行初始化。
  3. 接着可以通过对话的方式进行代码编写了。

以下是演示的生成界面的主体过程(中间有关闭窗口,没有完整的记录),这个界面首先来自,https://mp.weixin.qq.com/s/tafopd82apfEtCk2DXU0sg,我期望的是通过 AI 生成配置之后生成 CMS 站点的模版。这里主要就是生成配置页面的功能。

我的需求文档整理如下:

1
2
3
4
5
6
7
8
9
10
我现在想做为内容管理模块增加 AI 生成站点模板的功能。
通过模板管理的,[AI 模板] 进入对应的功能页面。

整个页面的核心就是两个部分一个是调整样式,一个是修改提示词,生成站点模板。

首先可以在页面左侧对网页样式进行调整,比如主题色、辅助色、字体、大小、字间距、按钮样式等。为了简化网页样式的调整,需要有兜底方案,首先你可以选择预设的各种风格模板,另外的话可以点击「随机生成」按钮,点完所有的元素都会变化,用户可以多点几次挑选自己喜欢的配置。

可以在右侧预览,预览需要做了三个页面首页、产品、博客,方便在不同的页面内容上预览,通过 Tab 进行切换。另外预览的部分还支持手机、电脑、平板不同页面宽度的预览,需要支持夜间模式。

最后用户可以设置补充默认的提示词内容,点击生成,把设置好的 JSON 和提示词调用 AI 接口生成对应的模板页面。

可以让 Claude 之间生成到 doc 目录下,初步返回的内容如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
1. 功能模块划分

核心模块:
- 样式配置器(左侧)
- 实时预览器(右侧)
- AI生成引擎

2. 样式配置器设计

预设风格模板:
- 现代简约(Linear风格)
- 商务专业
- 创意艺术
- 科技感
- 温暖亲和

样式配置维度:
interface StyleConfig {
theme: {
primaryColor: string;
secondaryColor: string;
accentColor: string;
backgroundColor: string;
surfaceColor: string;
};
typography: {
fontFamily: string;
baseFontSize: number;
headingScale: number;
lineHeight: number;
letterSpacing: number;
};
layout: {
borderRadius: number;
spacing: number;
maxWidth: number;
gridGap: number;
};
components: {
buttonStyle: 'filled' | 'outlined' | 'ghost';
cardElevation: number;
inputStyle: 'underline' | 'outline' | 'filled';
};
}

3. 预览器设计

多页面预览:
- 首页(Landing Page)
- 产品页(Product Showcase)
- 博客页(Blog Layout)

响应式预览:
- 手机(375px)
- 平板(768px)
- 桌面(1440px)

主题切换:
- 浅色模式
- 深色模式
- 自动跟随系统

4. AI生成引擎优化

提示词结构:
interface AIPrompt {
basePrompt: string; // 来自bootstrap-prompt.txt
styleConfig: StyleConfig;
contentType: 'landing' | 'product' | 'blog';
additionalRequirements: string[];
authorInfo: {
name: string;
socialLinks: string[];
};
}

生成流程:
1. 用户配置样式参数
2. 系统生成标准化JSON配置
3. 结合提示词调用AI接口
4. 生成完整HTML模板

5. 用户界面设计

左侧配置面板:
┌─────────────────────────────────────┐
│ 预设模板选择器 │
├─────────────────────────────────────┤
│ 主题色彩配置 │
│ ├── 主色调 ■ #3B82F6 │
│ ├── 辅助色 ■ #10B981 │
│ └── 背景色 ■ #FFFFFF │
├─────────────────────────────────────┤
│ 字体排版设置 │
│ ├── 字体族 [Inter] ▼ │
│ ├── 基础字号 16px [-][+] │
│ └── 行高 1.5 [-][+] │
├─────────────────────────────────────┤
│ 组件样式 │
│ ├── 按钮样式 [填充] ▼ │
│ └── 圆角半径 8px [-][+] │
├─────────────────────────────────────┤
│ [🎲 随机生成] [💾 保存配置] │
└─────────────────────────────────────┘

右侧预览面板:
┌─────────────────────────────────────┐
│ [首页] [产品] [博客] | 📱 💻 🖥️ 🌓 │
├─────────────────────────────────────┤
│ │
│ 实时预览区域 │
│ │
│ ╔═══════════════════════╗ │
│ ║ ║ │
│ ║ 模板预览内容 ║ │
│ ║ ║ │
│ ╚═══════════════════════╝ │
│ │
└─────────────────────────────────────┘

6. 技术实现方案

前端架构:
- Vue 3 Composition API
- Naive UI组件库
- UnoCSS样式框架
- Pinia状态管理

配置持久化:
- LocalStorage保存用户配置
- 支持导出/导入JSON配置
- 版本管理支持

AI集成:
- 封装通用AI调用接口
- 支持多种AI服务提供商
- 缓存机制优化性能

7. 交互优化

用户体验增强:
- 配置变更实时预览
- 撤销/重做功能
- 键盘快捷键支持
- 拖拽调整参数
- 批量应用样式

性能优化:
- 虚拟滚动长列表
- 防抖处理频繁操作
- 懒加载预览内容
- 压缩传输数据

8. 扩展功能

高级功能:
- 自定义CSS注入
- JavaScript交互增强
- SEO优化配置
- 性能指标监控
- A/B测试支持

这个设计方案充分考虑了用户友好性、技术可行性和扩展性,能够满足内容管理系统中AI生成站点模板的核心需求。

当时感觉已经和细化了,特别是有同事提到的 ascii 的 ui,于是让 Claude 继续生成。

63407f72e3b7c378f582db235b50aaff

一开始生成的页面是这样的,配置在左侧,Prompt在下面,不是太好的利用页面,于是让大模型把配置和提示词配置都放到右侧,用Tab 进行切换。

接下来发现一些交互的问题,直接发给Claude 进行修改。

![image-20250918230352310](/Users/airhead/Library/Application Support/typora-user-images/image-20250918230352310.png)

在这个过程中当中,注意即使提交 Git,以保留修改的成果,毕竟 AI 生成的也还是有些随机。另外一个就是因为项目是由 lint check的,如果 Claude 自己不能修改的情况,可以用 ROO CODE。一个模型发现不了的问题,另一个模型能发现。

整个环境验证下拉自己基本没有写代码,都是交给了 Claude Code 插件来处理的。

下面是完成这个功能,修改其中的BUG,产生的消费记录。

![image-20250918230945685](/Users/airhead/Library/Application Support/typora-user-images/image-20250918230945685.png)

通过这次AI 代码生成工具使用方法的调整,让我实现了一些我之前肯定做不出来的效果,而且我也基本上没有写代码,更重要的省钱省时间。

说明

《Solon 实用教程》是有计划写一个管理系统的完整示例的,但代码更新比较慢,也就没有迁移到示例仓库中。在调试前端代码的时候发现,对我来说,因为不熟悉前端,即便有了SoyBean 和 alovajs 开发起来也挺不容易。这段时间利用系统提供的代码生成功能,在 AI 的加持下,完成了内容管理的功能,算是验证了这个管理系统的基本可用。于是退而求其次,先把管理系统当前的功能做了些截图,也相当于是一个预览版本。如果公布了预览版本,也可以逼迫自己继续花时间来整理和开发这个管理端系统。

功能列表

  • 系统管理
    • 用户管理
    • 角色管理
    • 权限管理
    • 配置管理
    • 字典管理
  • 基础设施
    • 存储管理
      • 存储配置
      • 存储文件
  • 开发管理
    • 代码生成
      • 数据源配置
      • 生成配置
  • 内容管理
    • 文章管理
    • 栏目管理
    • 菜单管理
    • 站点管理
    • 模板管理
  • AI (进行中)
    • 聊天
    • 工作流

登录

img

首页

img

系统管理

用户管理

img

img

角色管理

img

权限管理

img

配置管理

img

字典管理

img

基础设施

存储管理

存储配置

img

存储文件

img

开发管理

代码生成

数据源配置

img

生成配置

img

img

内容管理

文章管理

img

栏目管理

img

菜单管理

img

站点管理

img

模版管理

img

img

AI

AI 部分是正在尝试去做的功能。

聊天

img

工作流

img

问题

升级 JDK 17 后,一个自定义请求,提示 The server selected protocol version TLS10 is not accepted的异常,这个问题,在之前应该是碰到过的,所以有记录了对应的链接,https://cloud.tencent.com/developer/article/2127522。但这次在客户那部署,虽然调整了对应的参数,但依然还是请求失败。趁着周末重新验证了这个 TLSv1 的请求。

img

处理

解封 TLSv1

因为 TLSv1,TLSv1.1 有已知的安全漏洞,所以在高版本的 JDK里面默认的禁用了 TLSv1,TLSv1.1 的安全算法。通过whereis 和 ll 等命令可以定位 JAVA_HOME 目录(也可以直接尝试 echo $JAVA_HOME 看看是否有配置对应的目录),接着定位到$JAVA_HOME/conf/security/java.security 文件。

找到 jdk.tls.disabledAlgorithms部分,旧内容如下:(不同JDK 版本可能略有不同)

1
2
jdk.tls.disabledAlgorithms=SSLv3, TLSv1, TLSv1.1, RC4, DES, MD5withRSA, \
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL

去除 TLSv1, TLSv1.1, ,修改如下:

1
2
jdk.tls.disabledAlgorithms=SSLv3, RC4, DES, MD5withRSA, \
DH keySize < 1024, EC keySize < 224, 3DES_EDE_CBC, anon, NULL

如果不想修改这个配置,也可以尝试的程序运行时指定参数。

1
java -Djdk.tls.client.protocols="TLSv1,TLSv1.1,TLSv1.2,TLSv1.3" -Djdk.tls.server.protocols="TLSv1,TLSv1.1,TLSv1.2,TLSv1.3" -jar app.jar

通常情况下,此时应该能正常使用,但发到现场后发现还是上面的错误。

模拟环境

从提示中我们可以看到,应该是对方的https 服务使用的是 TLSv1 版本的协议,于是修改自己的 nginx 服务强制指定ssl_protocols 为 TLSv1;

1
2
ssl_protocols TLSv1;
ssl_ciphers HIGH:!aNULL:!MD5;

此时浏览器范围页面也是异常的。

img

此时通过程序测试,提示的异常虽然有些不同,但基本可以看出是同一个问题引起的了。

img

尝试切换 hutool 的 httputil

切换 httputil 后发现服务请求自定义证书,提示下面的错误。

img

经过一番查看源代码后,发现 hutool 支持设置一个全局参数HttpGlobalConfig.setTrustAnyHost(true),设置了全局参数之后发现,请求正常了。

img

为什么 httputils 不行呢?

一开始以为是创建的 SSLContext 不同,更换成 hutool 工具创建的 SSLContext SSLContextUtil.createTrustAnySSLContext 依然还是不行。于是对比了下 hutool 和 solon 的是请求默认的类库不同,hutool 默认使用的是httpclient4,solon默认使用的是okhttp。当把 hutool 的默认请求切换到 okhttp 时,虽然一样也设置了*setTrustAnyHost*(true),但请求一样是报错的。

接着跟踪到 hutool 中 httpclient4 里面的一个关键设置。

1
2
3
private static SSLConnectionSocketFactory buildSocketFactory(SSLInfo sslInfo) {
return null == sslInfo ? SSLConnectionSocketFactory.getSocketFactory() : new SSLConnectionSocketFactory(sslInfo.getSslContext(), sslInfo.getProtocols(), (String[])null, sslInfo.getHostnameVerifier());
}

此时可以比较明确的确定是连接客户也需要设置的问题,于是找到 OkHttpClient 创建的位置,增加设置connectionSpecs

img

solon 作者说按上述设置会导致 http 无法访问,同时还发现 okhttp 还提供了一些常量,换成如下写法兼容性更好。

1
connectionSpecs(Arrays.asList(ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT))

临时处理

扩展 OkHttpUtilsFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class OkHttpUtilsFactoryExt extends OkHttpUtilsFactory {
static final Logger log = LoggerFactory.getLogger(OkHttpUtilsFactory.class);
private static final OkHttpUtilsFactory instance = new OkHttpUtilsFactory();
private static final OkHttpDispatcher dispatcher = new OkHttpDispatcher();
private final OkHttpClient defaultClient = createHttpClient(null, null);

private static OkHttpClient createHttpClient(Proxy proxy, HttpSslSupplier sslProvider) {
if (sslProvider == null) {
sslProvider = HttpSslSupplierDefault.getInstance();
}

OkHttpClient.Builder builder =
(new OkHttpClient.Builder())
.connectTimeout(10L, TimeUnit.SECONDS)
.writeTimeout(60L, TimeUnit.SECONDS)
.readTimeout(60L, TimeUnit.SECONDS)
.dispatcher(dispatcher.getDispatcher())
.addInterceptor(OkHttpInterceptor.instance)
.sslSocketFactory(sslProvider.getSocketFactory(), sslProvider.getX509TrustManager())
.connectionSpecs(Arrays.asList(ConnectionSpec.COMPATIBLE_TLS, ConnectionSpec.CLEARTEXT))
.hostnameVerifier(sslProvider.getHostnameVerifier());

if (proxy != null) {
builder.proxy(proxy);
}

return builder.build();
}

@Override
protected OkHttpClient getClient(Proxy proxy, HttpSslSupplier sslProvider) {
return createHttpClient(proxy, sslProvider);
}
}

扩展 HttpSslSupplier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class ExtensionHttp extends HttpSslSupplierDefault
implements HttpExtension, HttpSslSupplier {
// for HttpSslSupplier
private SSLContext sslContext;
private String[] protocols;

// for HttpExtension
@Override
public void onInit(HttpUtils httpUtils, String url) {
httpUtils.ssl(this);
}

@Override
public SSLContext getSslContext() {
return SSLContextUtil.createTrustAnySSLContext();
}
}

使用

如果指定了 HttpExtension的扩展为 Component,默认情况是会自动注册扩展的,测试时可能需要自己通过addExtension 的方式添加扩展。

1
2
HttpConfiguration.setFactory(new OkHttpUtilsFactoryExt());
HttpConfiguration.addExtension(new ExtensionHttp());

正式处理

等待 solon 3.5.0 版本的发布,之后切换 SSLContextUtil.createTrustAnySSLContext(),替换默认的SSLContext。

说明

最近已经陆续把公司的 Spring Boot 服务迁移到了 Solon 框架中来,期间碰到几次迁移后无法启动的问题或者启动慢问题。以下是问题的记录和处理办法。

问题 1

启动提示循环依赖,启动失败。

img

通常情况 Solon 的 Inject 是支持循环依赖的,有两种情况会因为循环依赖而启动失败,具体细节可以参考官网文档,「问题:产生 Bean 循环依赖怎么办?」

  1. 由构造函数产生的依赖
  2. 由初始化的依赖

而我碰的的刚好就是第二种情况,在就的 Spring Boot 服务中有一些 Spring Event 事件是通过过 PostConstruct 注解来注册。所以迁移过来的时候使用了 Init 的注解,如下代码。

img

但因为另一个类也存在 Init 的注解且相互依赖,所以无法正常启动,解决办法就是设置不同的优先级。

类 1

1
2
3
4
@Init(index = 2)
public void init() {
EventUtils.listen(ManageEvent.TOPIC_USER_UPDATE, new UpdateUserListener());
}

类 2

1
2
3
4
@Init(index = 1)
public void init() {
EventUtils.listen(ManageEvent.TOPIC_ORG_CREATE, new CreateOrgListener());
}

问题 2

应用启动慢

img

迁移了几个服务,通常情况下Solon 服务的启动会比 Spring Boot 的服务启动快。但有个服务器迁移过来的时候启动很慢,表现为扫描 Bean 对象很慢。

一开始时没有头绪的,后面问了作者,之后通过直接跟踪源代码,定位到慢的类,发现这个类是写得比较点怪的,是一个工具类,但为了获取其他 Bean 对象方便,把其他用的的类一起注入进来了,所以给这个类,用 PostConstruct 来做一次初始化,迁移过来的是自然就用了 Init 注解。这次扫描 Bean 慢,可能出现了较深的依赖关系,但还没形成循环依赖。

img

处理办法就是取消了 Init 的注解,AppLoadEndEvent 事件中,执行一次 Init 操作。

1
2
3
4
5
6
7
8
9
public class AppLoadEndEventListener implements EventListener<AppLoadEndEvent> {
@Inject private ESHelper esHelper;

@Override
public void onEvent(AppLoadEndEvent appLoadEndEvent) throws Throwable {

esHelper.init();
}
}

问题 3

旧的 Spring Boot 有个 Redis 消息队列的监听,于是修改成如下的代码。但发现项目启动的时候卡住了无法启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class RedisMessageConfig {

public static final String SCAN_JOB_MESSAGE = "scanJob-message";
/* 单次任务队列 */
public static final String SCAN_ONCE_MESSAGE = "scanOnce-message";

@Bean
public void init() {
RedisUtils.subscribe(new ScanJobSubscribe(), SCAN_JOB_MESSAGE);
RedisUtils.subscribe(new ScanJobSubscribe(), SCAN_ONCE_MESSAGE);
}
}

后面跟踪了代码才发现 redisx 的 subscribe,里面包含一个 process 其实是一个无限循环。在官网文档「应用生命周期」中有个提示:

重要提醒

  • 启动过程完成后,项目才能正常运行(启动过程中,不能把线程卡死了)
  • AppBeanLoadEndEvent 之前的事件,需要启动前完成订阅!!!(否则,时机错过了)

知道原因,处理起来就比较简单了,统一在 AppLoadEndEvent 事件中处理,并使用异步订阅。

1
2
3
4
5
6
7
8
9
10
@Slf4j
@Component
public class AppLoadEndEventListener implements EventListener<AppLoadEndEvent> {
@Inject private ScanJobSubscribe scanJobSubscribe;

@Override
public void onEvent(AppLoadEndEvent appLoadEndEvent) throws Throwable {
RedisUtils.subscribeFuture(scanJobSubscribe, SCAN_JOB_MESSAGE);
RedisUtils.subscribeFuture(scanJobSubscribe, SCAN_ONCE_MESSAGE);
}

小结

Solon 可能出于简单、快速等原因考虑,对待循环依赖是比较粗暴的,直接报错。但出现启动慢等问题时可能就不容易立刻定位问题,此时可以优先看看有没有使用 Init 注解,有没有使用 Bean 注解。这些相关的类是否有依赖,这些初始化函数是否把线程卡死了。

反过来说, Spring Boot 为了解决循环依赖方面还是做了很多的检查或者调整,所以可能导致启动速度慢一些。不过听说 Spring Boot 3.x 默认也禁用了循环依赖。

说明

最近在做的事情就是把 Spring Boot 的项目迁移到 Solon 上来。我们的有一个 OA 系统,里面使用到了 Flowable 6.5。照例我还是从官网入手,其中说到是不需要适配可以直接使用。然后附带了例子,https://gitee.com/hiro/flowable-solon-web。

看起来还是比较简单,但也没那么简单,还好之前有看网友提供了 easy-flowable,https://gitee.com/iajie/easy-flowable,中间也有请教作者几个问题,感谢。如果是新项目推荐使用,easy-flowable 不仅提供了封装和增强,还提供了不错的 UI编排界面,使得 flowable 更简单易用。

在统一看了相关的代码之后,主要的适配工作,就是根据配置设置ProcessEngineConfiguration, 并创建 ProcessEngine 及相关的 Service 的 bean 对象。

方法

在我们的系统中为一些基础组件采用 Solon 插件的方式封装一部分初始化工作。

配置

1
2
3
4
5
6
7
8
9
10
11
/**
* @author airhead
*/
@Configuration
@Inject(value = "${flowable}", autoRefreshed = true)
@Data
public class FlowableProperties {
private String databaseSchemaUpdate = "true";
private Boolean asyncExecutorActivate = false;
private String historyLevel = "audit";
}

构建 Bean

根据配置设置ProcessEngineConfiguration, 并创建 ProcessEngine 及相关的 Service 的 bean 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class XPluginImp implements Plugin {
private AppContext appContext;

@Override
public void start(AppContext context) throws Throwable {
appContext = context;

appContext.beanScan(FlowableProperties.class);

appContext.getBeanAsync(
DataSource.class,
dataSource -> {
FlowableProperties flowableProperties = appContext.getBean(FlowableProperties.class);

ProcessEngineConfigurationImpl engineConfiguration =
(ProcessEngineConfigurationImpl)
ProcessEngineConfiguration.createStandaloneInMemProcessEngineConfiguration();

engineConfiguration.setDataSource(dataSource);
engineConfiguration.setDatabaseSchemaUpdate("false");
// 设置流程历史级别
engineConfiguration.setHistoryLevel(
HistoryLevel.getHistoryLevelForKey(flowableProperties.getHistoryLevel()));

// 设置表达式管理器
ExpressionManager ficusExpressionManager = new SolonExpressionManager(appContext, null);
engineConfiguration.setExpressionManager(ficusExpressionManager);

// 定时任务开关
engineConfiguration.setAsyncExecutorActivate(
flowableProperties.getAsyncExecutorActivate());
appContext.wrapAndPut(ProcessEngineConfiguration.class, engineConfiguration);

ProcessEngine processEngine = engineConfiguration.buildProcessEngine();
appContext.wrapAndPut(ProcessEngine.class, processEngine);

RuntimeService runtimeService = processEngine.getRuntimeService();
appContext.wrapAndPut(RuntimeService.class, runtimeService);

RepositoryService repositoryService = processEngine.getRepositoryService();
appContext.wrapAndPut(RepositoryService.class, repositoryService);

IdentityService identityService = processEngine.getIdentityService();
appContext.wrapAndPut(IdentityService.class, identityService);

TaskService taskService = processEngine.getTaskService();
appContext.wrapAndPut(TaskService.class, taskService);

HistoryService historyService = processEngine.getHistoryService();
appContext.wrapAndPut(HistoryService.class, historyService);

ManagementService managementService = processEngine.getManagementService();
appContext.wrapAndPut(ManagementService.class, managementService);

FormService formService = processEngine.getFormService();
appContext.wrapAndPut(FormService.class, formService);

DynamicBpmnService dynamicBpmnService = processEngine.getDynamicBpmnService();
appContext.wrapAndPut(DynamicBpmnService.class, dynamicBpmnService);
});
}

@Override
public void stop() throws Throwable {
Plugin.super.stop();
}
}

表达管理器 (ProcessExpressionManager)

重要,如果没有添加自己的表达式管理,一些流程在执行的时候可能出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* @author airhead
*/
public class SolonExpressionManager extends ProcessExpressionManager {
protected AppContext appContext;

public SolonExpressionManager(AppContext appContext, Map<Object, Object> beans) {
super(beans);
this.appContext = appContext;
}

@Override
protected ELResolver createElResolver(VariableContainer variableContainer) {
List<ELResolver> elResolvers = new ArrayList<>();
elResolvers.add(createVariableElResolver(variableContainer));
elResolvers.add(new AppContextElResolver(this.appContext));
if (beans != null) {
elResolvers.add(new ReadOnlyMapELResolver(beans));
}
elResolvers.add(new ArrayELResolver());
elResolvers.add(new ListELResolver());
elResolvers.add(new MapELResolver());
elResolvers.add(new JsonNodeELResolver());
ELResolver beanElResolver = createBeanElResolver();
if (beanElResolver != null) {
elResolvers.add(beanElResolver);
}

configureResolvers(elResolvers);

CompositeELResolver compositeElResolver = new CompositeELResolver();
for (ELResolver elResolver : elResolvers) {
compositeElResolver.add(elResolver);
}
compositeElResolver.add(new CouldNotResolvePropertyELResolver());
return compositeElResolver;
}
}

ELResolver

EL 表达式执行器,用于处理 Solon bean 对象的获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
* @author airhead
*/
@Slf4j
public class AppContextElResolver extends ELResolver {
protected AppContext appContext;

public AppContextElResolver(AppContext appContext) {
this.appContext = appContext;
}

@Override
public Class<?> getCommonPropertyType(ELContext context, Object arg) {
return Object.class;
}

@Override
public Iterator<FeatureDescriptor> getFeatureDescriptors(ELContext context, Object arg) {
return null;
}

@Override
public Class<?> getType(ELContext context, Object arg1, Object arg2) {
return Object.class;
}

@Override
public Object getValue(ELContext context, Object base, Object property) {
if (base == null) {
String key = property.toString();
Object bean = appContext.getBean(key);
if (bean != null) {
context.setPropertyResolved(true);
return bean;
}
}
return null;
}

@Override
public boolean isReadOnly(ELContext context, Object base, Object property) {
return true;
}

@Override
public void setValue(ELContext context, Object base, Object property, Object value) {
if (base == null) {
String key = (String) property;
this.appContext.getBeanAsync(
key,
(bean) -> {
throw new FlowableException(
"Cannot set value of '"
+ property
+ "', it resolves to a bean defined in the Solon application-context.");
});
}
}
}

问题

  1. 提示模型注册失败,并提示 java.time.LocalDateTime cannot be cast to java.lang.String

img

img

主要的问题,集成的是 6.5.0的 Flowable,不支持高版本的 MySQL 驱动。检查发现Spring 版本使用的是 8.0.20的MySQL 驱动,而 Solon 使用的是 8.0.30 版本的驱动。把MySQL 驱动版本恢复为8.0.20,服务启动成功。

  1. 提示找不到对象 TaskListener 相关的表达式对象,一些流程条件的时候提示如下的错误。

img

原因是虽然通过 Component 注册了 Bean 对象,但 Flowable 无法获取,需有重写 ExpressionManager,增加Solon bean 对象的获取。

这里需要注意,注册 ExpressionManager 时,需要在 ProcessEngine processEngine = engineConfiguration.buildProcessEngine()之前否则可能构建了两个ExpressionManager,在执行获取 TaskListener 的对象时仍然可能报错。

1
2
3
// 设置表达式管理器
ExpressionManager ficusExpressionManager = new SolonExpressionManager(appContext, null);
engineConfiguration.setExpressionManager(ficusExpressionManager);

增加 SolonExpressionManager,继承自 ProcessExpressionManager。重写 EL 表达式执行器,多增加AppContextElResolver。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
protected ELResolver createElResolver(VariableContainer variableContainer) {
List<ELResolver> elResolvers = new ArrayList<>();
elResolvers.add(createVariableElResolver(variableContainer));
elResolvers.add(new AppContextElResolver(this.appContext));
if (beans != null) {
elResolvers.add(new ReadOnlyMapELResolver(beans));
}
elResolvers.add(new ArrayELResolver());
elResolvers.add(new ListELResolver());
elResolvers.add(new MapELResolver());
elResolvers.add(new JsonNodeELResolver());
ELResolver beanElResolver = createBeanElResolver();
if (beanElResolver != null) {
elResolvers.add(beanElResolver);
}

configureResolvers(elResolvers);

CompositeELResolver compositeElResolver = new CompositeELResolver();
for (ELResolver elResolver : elResolvers) {
compositeElResolver.add(elResolver);
}
compositeElResolver.add(new CouldNotResolvePropertyELResolver());
return compositeElResolver;
}

增加 AppContextElResolver,继承自 ELResolver,获取 Bean 对象的代码,这里需要注意如果对象创建了,需要设置context.setPropertyResolved(true)

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public Object getValue(ELContext context, Object base, Object property) {
if (base == null) {
String key = property.toString();
Object bean = appContext.getBean(key);
if (bean != null) {
context.setPropertyResolved(true);
return bean;
}
}
return null;
}

说明

在「使用 Solon Cloud Gateway 替换Spring Cloud Gateway 」的文章中,有评论说不知道响应式。当时看的是

Solon Cloud Gateway 使用响应式接口,由 Solon-Rx 来实现,是基于 reactive-streams 封装的 RxJava 极简版。目前仅一个接口 Completable,所以当时以为实现了 ExFilter 返回 Completable 就是响应式了。后续 Solon Cloud Gateway 继续更新文档之后才发现,虽然网关是响应式了,但如果逻辑涉及 IO 或者比较慢的操作,需要在 ExFilter 中开启异步接口,这样才是真正的异步,从而避免对网关的事件循环器造成影响,减少对响应式的性能的伤害。

为了对接同步 IO 接口,Solon 3.2.1 提供了 CloudGatewayFilterSync,用于简化异步调用的编写。

基础版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//同步过滤器(会自动转异步)
@FunctionalInterfacepublic interface ExFilterSync extends ExFilter {
@Overridedefault Completable doFilter(ExContext ctx, ExFilterChain chain) {
return Completable.create(emitter -> {
//暂停接收流
ctx.pause();

//开始异步
RunUtil.async(() -> {
try {
//开始同步处理boolean isContinue = doFilterSync(ctx);

if (isContinue) {
//继续
chain.doFilter(ctx).subscribe(emitter);
} else {
//结束
emitter.onComplete();
}
} catch (Throwable ex) {
emitter.onError(ex);
}
});
});
}

/**
* 执行过滤同步处理(一般用于同步 io)
*
* @param ctx 上下文
* @return 是否继续
*/boolean doFilterSync(ExContext ctx) throws Throwable;
}

这个 doFilter 的过程中需要自己先处理 ctx.pause,然后开启异步,并在开启的过程中判断是否异常的提交等等,需要写好这个 ExFitler 的扩展还是有点不容易的。

于是作者又贴心的提供了CloudGatewayFilterSync

简化版

1
2
3
4
5
6
7
8
9
10
11
@Component(index = -9)
public class AuthFilterSync implements CloudGatewayFilterSync {
@Inject
AuthJdbcService authService;

@Overridepublic boolean doFilterSync(ExContext ctx) throws Throwable {
String userId = ctx.rawQueryParam("userId");
//检测路径权限
return authService.check(userId, ctx.rawPath());
}
}

在简化的版本中,只需要在 doFilterSync 中编写具体的逻辑,而不要关心流的暂停和开启异步等。如果已经处理完毕,不需要继续执行返回 ture,否则返回 false。

参考

https://solon.noear.org/article/813

https://solon.noear.org/article/1005

说明

在「 操作 SQL 」中说到,个人比较喜欢 ActiveRecord,后续也会补充对 easy-query 的扩展,比如集成 ActiveRecord 的 SQL 模版管理,基于 Map 的 Model Bean。

可能自己最早是开始从 SQL 开始也业务的,不是一开始就接触 ORM,所以个人习惯使用 SQL 进行代码的编写,特别是有连接的时候,虽然 easy-query 对 join 处理已经很好了,但我还是觉得书写比较麻烦。好在 easy-query提供了原生的 SQL 查询的支持,因此只需要增加 SQL 模版的管理功能,就能使用安心的使用 SQL 写查询了。

在使用 Mybatis-Plus 的过程中发现,这个 ORM 框架在多租户是最无感的。因为引入了 jsqlparser,不论是普通的 orm 的 dsl 写法,还是 SQL 写法都不需要关心租户字段。其他的一些 ORM 框架只是关注 dsl 的写法是能租户字段无感的,在写 SQL 时就需要自己处理租户字段,easy-query 也不例外,jfinal 也是如此。

基于 Map 的 Model Bean,或者 ActiveRecord 的一个好处就是对查询结果的动态扩展或者裁剪,当然这都是要配合 Jfinal 自己的 JSON 序列化,否则动态增加的字段是不会被处理的,另外就是 Jfinal 还提供了 keep 和 remove 等方法可以对数据进行裁剪。这样的好处是什么呢?在代码中可以统一使用一套 Modle 进行传递,而不是各种 Vo 和 Dto 的转换。

自己使用 easy-query 的也不是很熟练,所以这次的调整不一定很完整,这里相当于是提供了一个思路供大家。阅读本文的同学根据自己的情况谨慎使用和自行修改。因为本次的代码的集成修改在另一个项目中(依赖有其他项目的依赖,增加了easyquery和easy-query-web 插件),还没有整理到 porpoise-demo 项目中,所以暂时看不了源码。后续剥离项目的依赖后会陆续上传到 porpoise-demo 中。

Enjoy

因为 AtiveRecord 使用了 enjoy ,做了模版管理的基础,因此可以使用引入依赖包的方式引入,然而原生的 enjoy 对 solon 的容器支持问题(https://gitee.com/jfinal/jfinal/pulls/119),solon 对 enjoy 进行了重新编译。在使用的过程中,发现其实单独版本的 enjoy 和 activerecord 还是有些裁剪的。因此自己整理重新 fork 了一个enjoy 和 activerecord 的版本,基于 5.2.2 版本做了些修改,代码托管在 coding。

地址:

https://e.coding.net/goldsyear/allblue/porpoise-jfinal.git

坐标:

implementation(“com.goldsyear:enjoy:1.0.0”)

ActiveRecord

增加 acitverecord 代码

首先 easy-query 提供了 solon 的插件,将 easy-query 的 solon 插件克隆出来。jfinal 的 ActiveRecord 主要 plugin/activerecord 目录。因此先把两个目录整合在一起,修复其中的编译错误,一些缺失不会处理的可以先屏蔽,确保已经被编辑过来。

img

初始化 SqlKit

Jfinal 通过 SqlKit 管理 SQL 模版的加载。因此在构建的 easy-qeury 的 bean 对象的时候,一起初始化 SqlKit,加载对应的SQL 模版。

EasyQueryHolder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.easy.query.solon.integration.holder;

import com.easy.query.core.api.client.EasyQueryClient;
import com.easy.query.solon.activerecord.sql.SqlKit;
import org.noear.solon.core.VarHolder;

/**
* create time 2023/7/24 22:19 文件说明
*
* @author xuejiaming
*/
public interface EasyQueryHolder {
String MAIN_CONFIG_NAME = "main";

/**
* 获取sqlKet
*
* @return
*/
SqlKit sqlKit();

// 以下代码省略,方便说明修改 ...
}

DbManger

在构建增加 SqlKit 的初始化,这个通过读取配置,获取 SQL 的文件位置并读取.sql的文件进行装在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/** 构建 */
private static EasyQueryHolder build(BeanWrap bw) {
// 以上代码省略,方便说明修改 ...

// 引入对应的sql模版
SqlKit sqlKit = new SqlKit(configName, solonEasyQueryProperties.getSqlDev());
String sqlPath = solonEasyQueryProperties.getSqlPath();
ScanUtil.scan(sqlPath, n -> n.endsWith(".sql")).forEach(sqlKit::addSqlTemplate);
sqlKit.parseSqlTemplate();
SqlManager.add(sqlKit);
EventBus.publish(sqlKit);

return injectHolderFactory.get().getHolder(configName, easyQueryClient, sqlKit);
}

使用

通过SqlManger(对应DbManger),获取SQL 语法,直接交给 entityQueryable 进行原生的 SQL 查询,而且可以进行类型转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 通过sql模板,查询数据
*
* @param template
* @param data
* @param vClass
* @return
* @param <V>
*/
public <V> List<V> listAs(String template, Map<?, ?> data, Class<V> vClass) {
SqlPara sqlPara = SqlManager.getSqlPara(template, data);
return entityQueryable(sqlPara.getSql(), Arrays.asList(sqlPara.getPara()))
.select(vClass)
.toList();
}

SQL 租户

Easy-query 的 DefaultEntityExpressionExecutor 支持在 querySQL 中对原生的SQL 进行处理,可以在这里增加对租户的逻辑的处理。

依赖

1
api("com.github.jsqlparser:jsqlparser:5.1")

DefaultEntityExpressionExecutorEx

增加租户逻辑的处理,如果存在 TenantLineHandler 的bean 对象,且没有设置忽略租户的时候就进行解析SQL语法,并进行租户的过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.easy.query.solon.integration.excutor;

import com.easy.query.core.basic.jdbc.executor.DefaultEntityExpressionExecutor;
import com.easy.query.core.basic.jdbc.executor.ExecutorContext;
import com.easy.query.core.basic.jdbc.executor.ResultMetadata;
import com.easy.query.core.basic.jdbc.parameter.SQLParameter;
import com.easy.query.core.expression.executor.parser.EasyPrepareParser;
import com.easy.query.core.expression.executor.query.ExecutionContextFactory;
import com.easy.query.core.inject.ServiceProvider;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.noear.solon.core.AppContext;

/**
* @author airhead
*/
@Slf4j
public class DefaultEntityExpressionExecutorEx extends DefaultEntityExpressionExecutor {
private final ServiceProvider serviceProvider;

public DefaultEntityExpressionExecutorEx(
EasyPrepareParser easyPrepareParser,
ExecutionContextFactory executionContextFactory,
ServiceProvider serviceProvider) {
super(easyPrepareParser, executionContextFactory);
this.serviceProvider = serviceProvider;
}

@Override
public <TR> List<TR> querySQL(
ExecutorContext executorContext,
ResultMetadata<TR> resultMetadata,
String sql,
List<SQLParameter> sqlParameters) {
AppContext appContext = serviceProvider.getService(AppContext.class);
TenantLineHandler tenantLineHandler = appContext.getBean(TenantLineHandler.class);
if (tenantLineHandler == null || tenantLineHandler.ignoreTenant()) {
return super.querySQL(executorContext, resultMetadata, sql, sqlParameters);
}

sql = tenantLineHandler.parseSql(sql, null);
return super.querySQL(executorContext, resultMetadata, sql, sqlParameters);
}
}

TenantLineHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.easy.query.solon.integration.excutor;

import com.easy.query.solon.integration.parser.SqlParser;
import com.easy.query.solon.integration.parser.TenantLineSqlParser;
import net.sf.jsqlparser.expression.*;

/**
* @author airhead
*/
public interface TenantLineHandler {

/**
* 获取租户id参数
*
* @return
*/
Expression getTenantId();

/**
* 获取租户id字段
*
* @return
*/
default String getTenantIdColumn() {
return "tenant_id";
}

/**
* 是否忽略租户
*
* @return
*/
default boolean ignoreTenant() {
return false;
}

/**
* 是否忽略表格
*
* @param tableName
* @return
*/
default boolean ignoreTable(String tableName) {
return false;
}

/**
* 获取SQL 解析器
*
* @return
*/
default SqlParser getSqlParser() {
return new TenantLineSqlParser(this);
}

/**
* 解析SQL
*
* @param sql
* @param obj
* @return
*/
default String parseSql(String sql, Object obj) {
return getSqlParser().parserSingle(sql, obj);
}
}

SqlParser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package com.easy.query.solon.integration.parser;

import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.Statements;
import net.sf.jsqlparser.statement.delete.Delete;
import net.sf.jsqlparser.statement.insert.Insert;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.update.Update;

/**
* @author airhead
*/
public interface SqlParser {

/**
* 解析单条 SQL
*
* @param sql
* @param obj
* @return
*/
default String parserSingle(String sql, Object obj) {
try {
Statement statement = CCJSqlParserUtil.parse(sql);
return this.processParser(statement, 0, sql, obj);
} catch (JSQLParserException e) {
throw new RuntimeException(
String.format("Failed to process, Error SQL: %s", sql), e.getCause());
}
}

/**
* 解析多条 SQL
*
* @param sql
* @param obj
* @return
*/
@SuppressWarnings("deprecation")
default String parserMulti(String sql, Object obj) {
try {
StringBuilder sb = new StringBuilder();
Statements statements = CCJSqlParserUtil.parseStatements(sql);
int i = 0;

for (Statement statement : statements.getStatements()) {
if (i > 0) {
sb.append(";");
}

sb.append(this.processParser(statement, i, sql, obj));
++i;
}

return sb.toString();
} catch (JSQLParserException e) {
throw new RuntimeException(
String.format("Failed to process, Error SQL: %s", sql), e.getCause());
}
}

default String processParser(Statement statement, int index, String sql, Object obj) {
if (statement instanceof Insert) {
this.processInsert((Insert) statement, index, sql, obj);
} else if (statement instanceof Select) {
this.processSelect((Select) statement, index, sql, obj);
} else if (statement instanceof Update) {
this.processUpdate((Update) statement, index, sql, obj);
} else if (statement instanceof Delete) {
this.processDelete((Delete) statement, index, sql, obj);
}

sql = statement.toString();

return sql;
}

default void processInsert(Insert insert, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processDelete(Delete delete, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processUpdate(Update update, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}

default void processSelect(Select select, int index, String sql, Object obj) {
throw new UnsupportedOperationException();
}
}

TenantLineSqlParser

重点处理 select,如果是插入同时是对单表的操作,使用 ORM 的 dsl 进行操作就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
package com.easy.query.solon.integration.parser;

import com.easy.query.solon.integration.excutor.TenantLineHandler;
import java.util.*;
import java.util.stream.Collectors;
import net.sf.jsqlparser.expression.*;
import net.sf.jsqlparser.expression.operators.conditional.AndExpression;
import net.sf.jsqlparser.expression.operators.conditional.OrExpression;
import net.sf.jsqlparser.expression.operators.relational.*;
import net.sf.jsqlparser.schema.Column;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.select.*;
import org.dromara.hutool.core.collection.CollUtil;

/**
* @author airhead
*/
public class TenantLineSqlParser implements SqlParser {
private final TenantLineHandler tenantLineHandler;

public TenantLineSqlParser(TenantLineHandler tenantLineHandler) {
this.tenantLineHandler = tenantLineHandler;
}

@Override
public void processSelect(Select select, int index, String sql, Object obj) {
String whereSegment = (String) obj;
this.processSelectBody(select.getPlainSelect(), whereSegment);
List<WithItem<?>> withItemsList = select.getWithItemsList();
if (CollUtil.isNotEmpty(withItemsList)) {
withItemsList.forEach(
(withItem) -> this.processSelectBody(withItem.getSelect(), whereSegment));
}
}

protected void processSelectBody(Select selectBody, final String whereSegment) {
if (selectBody == null) {
return;
}

if (selectBody instanceof PlainSelect) {
processPlainSelect((PlainSelect) selectBody, whereSegment);
} else if (selectBody instanceof ParenthesedSelect parenthesedSelect) {
processSelectBody(parenthesedSelect.getSelect(), whereSegment);
} else if (selectBody instanceof SetOperationList operationList) {
List<Select> selectBodyList = operationList.getSelects();
if (CollUtil.isNotEmpty(selectBodyList)) {
selectBodyList.forEach(body -> processSelectBody(body, whereSegment));
}
}
}

protected void processPlainSelect(final PlainSelect plainSelect, final String whereSegment) {
List<SelectItem<?>> selectItems = plainSelect.getSelectItems();
if (CollUtil.isNotEmpty(selectItems)) {
selectItems.forEach((selectItem) -> this.processSelectItem(selectItem, whereSegment));
}

Expression where = plainSelect.getWhere();
this.processWhereSubSelect(where, whereSegment);
FromItem fromItem = plainSelect.getFromItem();
List<Table> list = this.processFromItem(fromItem, whereSegment);
List<Table> mainTables = new ArrayList<>(list);
List<Join> joins = plainSelect.getJoins();
if (CollUtil.isNotEmpty(joins)) {
mainTables = this.processJoins(mainTables, joins, whereSegment);
}

if (CollUtil.isNotEmpty(mainTables)) {
plainSelect.setWhere(this.builderExpression(where, mainTables, whereSegment));
}
}

@SuppressWarnings({"rawtypes", "unchecked"})
protected void processWhereSubSelect(Expression where, final String whereSegment) {
if (where == null) {
return;
}
if (where instanceof FromItem) {
processOtherFromItem((FromItem) where, whereSegment);
return;
}
if (where.toString().indexOf("SELECT") > 0) {
// 有子查询
if (where instanceof BinaryExpression expression) {
// 比较符号 , and , or , 等等
processWhereSubSelect(expression.getLeftExpression(), whereSegment);
processWhereSubSelect(expression.getRightExpression(), whereSegment);
} else if (where instanceof InExpression expression) {
// in
Expression inExpression = expression.getRightExpression();
if (inExpression instanceof Select) {
processSelectBody(((Select) inExpression), whereSegment);
}
} else if (where instanceof ExistsExpression expression) {
// exists
processWhereSubSelect(expression.getRightExpression(), whereSegment);
} else if (where instanceof NotExpression expression) {
// not exists
processWhereSubSelect(expression.getExpression(), whereSegment);
} else if (where instanceof ParenthesedExpressionList) {
ParenthesedExpressionList<Expression> expression = (ParenthesedExpressionList) where;
processWhereSubSelect(expression.get(0), whereSegment);
}
}
}

@SuppressWarnings("rawtypes")
protected void processSelectItem(SelectItem selectItem, final String whereSegment) {
Expression expression = selectItem.getExpression();
if (expression instanceof Select) {
processSelectBody(((Select) expression), whereSegment);
} else if (expression instanceof Function) {
processFunction((Function) expression, whereSegment);
} else if (expression instanceof ExistsExpression existsExpression) {
processSelectBody((Select) existsExpression.getRightExpression(), whereSegment);
}
}

/**
* 处理函数
*
* <p>支持: 1. select fun(args..) 2. select fun1(fun2(args..),args..)
*
* <p>
*
* <p>fixed gitee pulls/141
*
* @param function
*/
protected void processFunction(Function function, final String whereSegment) {
ExpressionList<?> parameters = function.getParameters();
if (parameters != null) {
parameters.forEach(
expression -> {
if (expression instanceof Select) {
processSelectBody(((Select) expression), whereSegment);
} else if (expression instanceof Function) {
processFunction((Function) expression, whereSegment);
} else if (expression instanceof EqualsTo) {
if (((EqualsTo) expression).getLeftExpression() instanceof Select) {
processSelectBody(
((Select) ((EqualsTo) expression).getLeftExpression()), whereSegment);
}
if (((EqualsTo) expression).getRightExpression() instanceof Select) {
processSelectBody(
((Select) ((EqualsTo) expression).getRightExpression()), whereSegment);
}
}
});
}
}

protected void processOtherFromItem(FromItem fromItem, final String whereSegment) {
// 去除括号
while (fromItem instanceof ParenthesedFromItem) {
fromItem = ((ParenthesedFromItem) fromItem).getFromItem();
}

if (fromItem instanceof ParenthesedSelect) {
Select subSelect = (Select) fromItem;
processSelectBody(subSelect, whereSegment);
}
}

/** 处理条件 */
protected Expression builderExpression(
Expression currentExpression, List<Table> tables, final String whereSegment) {
// 没有表需要处理直接返回
if (CollUtil.isEmpty(tables)) {
return currentExpression;
}
// 构造每张表的条件
List<Expression> expressions =
tables.stream()
.map(item -> buildTableExpression(item, currentExpression, whereSegment))
.filter(Objects::nonNull)
.collect(Collectors.toList());

// 没有表需要处理直接返回
if (CollUtil.isEmpty(expressions)) {
return currentExpression;
}

// 注入的表达式
Expression injectExpression = expressions.get(0);
// 如果有多表,则用 and 连接
if (expressions.size() > 1) {
for (int i = 1; i < expressions.size(); i++) {
injectExpression = new AndExpression(injectExpression, expressions.get(i));
}
}

if (currentExpression == null) {
return injectExpression;
}
if (currentExpression instanceof OrExpression) {
return new AndExpression(
new ParenthesedExpressionList<>(currentExpression), injectExpression);
} else {
return new AndExpression(currentExpression, injectExpression);
}
}

/**
* 租户字段别名设置
*
* <p>tenantId 或 tableAlias.tenantId
*
* @param table 表对象
* @return 字段
*/
protected Column getAliasColumn(Table table) {
StringBuilder column = new StringBuilder();
// todo 该起别名就要起别名,禁止修改此处逻辑
if (table.getAlias() != null) {
column.append(table.getAlias().getName()).append(".");
}
column.append(tenantLineHandler.getTenantIdColumn());
return new Column(column.toString());
}

/**
* 构建租户条件表达式
*
* @param table 表对象
* @param where 当前where条件
* @param whereSegment 所属Mapper对象全路径(在原租户拦截器功能中,这个参数并不需要参与相关判断)
* @return 租户条件表达式
*/
protected Expression buildTableExpression(
final Table table, final Expression where, final String whereSegment) {
if (tenantLineHandler.ignoreTable(table.getName())) {
return null;
}

return new EqualsTo(getAliasColumn(table), tenantLineHandler.getTenantId());
}

protected List<Table> processFromItem(FromItem fromItem, final String whereSegment) {
// 处理括号括起来的表达式
// while (fromItem instanceof ParenthesedFromItem) {
// fromItem = ((ParenthesedFromItem) fromItem).getFromItem();
// }

List<Table> mainTables = new ArrayList<>();
// 无 join 时的处理逻辑
if (fromItem instanceof Table) {
Table fromTable = (Table) fromItem;
mainTables.add(fromTable);
} else if (fromItem instanceof ParenthesedFromItem) {
// SubJoin 类型则还需要添加上 where 条件
List<Table> tables = processSubJoin((ParenthesedFromItem) fromItem, whereSegment);
mainTables.addAll(tables);
} else {
// 处理下 fromItem
processOtherFromItem(fromItem, whereSegment);
}
return mainTables;
}

/**
* 处理 sub join
*
* @param subJoin subJoin
* @return Table subJoin 中的主表
*/
protected List<Table> processSubJoin(ParenthesedFromItem subJoin, final String whereSegment) {
while (subJoin.getJoins() == null && subJoin.getFromItem() instanceof ParenthesedFromItem) {
subJoin = (ParenthesedFromItem) subJoin.getFromItem();
}
List<Table> tableList = processFromItem(subJoin.getFromItem(), whereSegment);
List<Table> mainTables = new ArrayList<>(tableList);
if (subJoin.getJoins() != null) {
processJoins(mainTables, subJoin.getJoins(), whereSegment);
}
return mainTables;
}

/**
* 处理 joins
*
* @param mainTables 可以为 null
* @param joins join 集合
* @return List<Table> 右连接查询的 Table 列表
*/
protected List<Table> processJoins(
List<Table> mainTables, List<Join> joins, final String whereSegment) {
// join 表达式中最终的主表
Table mainTable = null;
// 当前 join 的左表
Table leftTable = null;

if (mainTables.size() == 1) {
mainTable = mainTables.get(0);
leftTable = mainTable;
}

// 对于 on 表达式写在最后的 join,需要记录下前面多个 on 的表名
Deque<List<Table>> onTableDeque = new LinkedList<>();
for (Join join : joins) {
// 处理 on 表达式
FromItem joinItem = join.getRightItem();

// 获取当前 join 的表,subJoint 可以看作是一张表
List<Table> joinTables = null;
if (joinItem instanceof Table) {
joinTables = new ArrayList<>();
joinTables.add((Table) joinItem);
} else if (joinItem instanceof ParenthesedFromItem) {
joinTables = processSubJoin((ParenthesedFromItem) joinItem, whereSegment);
}

if (joinTables != null && !joinTables.isEmpty()) {

// 如果是隐式内连接
if (join.isSimple()) {
mainTables.addAll(joinTables);
continue;
}

// 当前表是否忽略
Table joinTable = joinTables.get(0);

List<Table> onTables = null;
// 如果不要忽略,且是右连接,则记录下当前表
if (join.isRight()) {
mainTable = joinTable;
mainTables.clear();
if (leftTable != null) {
onTables = Collections.singletonList(leftTable);
}
} else if (join.isInner()) {
if (mainTable == null) {
onTables = Collections.singletonList(joinTable);
} else {
onTables = Arrays.asList(mainTable, joinTable);
}
mainTable = null;
mainTables.clear();
} else {
onTables = Collections.singletonList(joinTable);
}

if (mainTable != null && !mainTables.contains(mainTable)) {
mainTables.add(mainTable);
}

// 获取 join 尾缀的 on 表达式列表
Collection<Expression> originOnExpressions = join.getOnExpressions();
// 正常 join on 表达式只有一个,立刻处理
if (originOnExpressions.size() == 1 && onTables != null) {
List<Expression> onExpressions = new LinkedList<>();
onExpressions.add(
builderExpression(originOnExpressions.iterator().next(), onTables, whereSegment));
join.setOnExpressions(onExpressions);
leftTable = mainTable == null ? joinTable : mainTable;
continue;
}
// 表名压栈,忽略的表压入 null,以便后续不处理
onTableDeque.push(onTables);
// 尾缀多个 on 表达式的时候统一处理
if (originOnExpressions.size() > 1) {
Collection<Expression> onExpressions = new LinkedList<>();
for (Expression originOnExpression : originOnExpressions) {
List<Table> currentTableList = onTableDeque.poll();
if (CollUtil.isEmpty(currentTableList)) {
onExpressions.add(originOnExpression);
} else {
onExpressions.add(
builderExpression(originOnExpression, currentTableList, whereSegment));
}
}
join.setOnExpressions(onExpressions);
}
leftTable = joinTable;
} else {
processOtherFromItem(joinItem, whereSegment);
leftTable = null;
}
}

return mainTables;
}
}

DbManager

在DbManager 中替换默认的 EntityExpressionExecutor。

1
2
3
4
5
easyQueryBuilderConfiguration
.replaceService(DataSourceUnitFactory.class, SolonDataSourceUnitFactory.class)
.replaceService(ConnectionManager.class, SolonConnectionManager.class)
.replaceService(appContext)
.replaceService(EntityExpressionExecutor.class, DefaultEntityExpressionExecutorEx.class);

使用租户

使用时只需要继承 TenantLineHandler,设置对应的租户字段的处理,构建为Bean 对象就可以了。

结构裁剪

虽然 solon 可以设置数据为 null 时不返回属性,但这个和返回结果的裁剪还是有不同。如果通过 easy-query 来实现的话,就需要增加对的 Vo,或者 Dto 来承接数据。这里提供对 Model 进行封装的方式实现对返回结果结构的裁剪。

BaseModel

通过一个baseModel 看和普通model的不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
package com.goldsyear.porpoise.module.dev.code.model.base;

import com.easy.query.core.annotation.Column;
import com.easy.query.solon.activerecord.IBean;
import com.easy.query.solon.activerecord.Model;
import java.time.LocalDateTime;

/**
* 1. private 变量在 Getter 和 Setter 中没有被用到。之所以定义它们,是因为很多序列化框架的需要 private 变量和 Setter 必须同时存在才能成功。 2.
* 方便设置 easy-query 的注解
*
* <p>Generated by Porpoise, please do not modify this file.
*
* @author airhead
*/
@SuppressWarnings({"unused"})
public abstract class BaseCodeDb<M extends BaseCodeDb<M>> extends Model<M> implements IBean {

/** 主键编号 */
@Column(primaryKey = true, generatedKey = true)
private Integer id;

/** 名称 */
private String name;

/** 连接 */
private String url;

/** 用户名 */
private String username;

/** 密码 */
private String password;

/** 创建者 */
private String creator;

/** 创建时间 */
private LocalDateTime createTime;

/** 更新者 */
private String updater;

/** 更新时间 */
private LocalDateTime updateTime;

/** 主键编号 */
public Integer getId() {
return getInt("id");
}

/** 主键编号 */
public void setId(Integer id) {
set("id", id);
}

/** 名称 */
public String getName() {
return getStr("name");
}

/** 名称 */
public void setName(String name) {
set("name", name);
}

/** 连接 */
public String getUrl() {
return getStr("url");
}

/** 连接 */
public void setUrl(String url) {
set("url", url);
}

/** 用户名 */
public String getUsername() {
return getStr("username");
}

/** 用户名 */
public void setUsername(String username) {
set("username", username);
}

/** 密码 */
public String getPassword() {
return getStr("password");
}

/** 密码 */
public void setPassword(String password) {
set("password", password);
}

/** 创建者 */
public String getCreator() {
return getStr("creator");
}

/** 创建者 */
public void setCreator(String creator) {
set("creator", creator);
}

/** 创建时间 */
public LocalDateTime getCreateTime() {
return getLocalDateTime("create_time");
}

/** 创建时间 */
public void setCreateTime(LocalDateTime createTime) {
set("create_time", createTime);
}

/** 更新者 */
public String getUpdater() {
return getStr("updater");
}

/** 更新者 */
public void setUpdater(String updater) {
set("updater", updater);
}

/** 更新时间 */
public LocalDateTime getUpdateTime() {
return getLocalDateTime("update_time");
}

/** 更新时间 */
public void setUpdateTime(LocalDateTime updateTime) {
set("update_time", updateTime);
}
}

ModelUtils

通过 ModelUtils,可以对查询结果进行裁剪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
package com.goldsyear.solon.web.util;

import com.easy.query.solon.activerecord.Model;
import com.goldsyear.solon.web.model.PageResult;
import java.util.List;
import org.dromara.hutool.core.collection.CollUtil;

/**
* @author airhead
*/
public class ModelUtils {
public static <M extends Model<M>> M remove(M model, String attr) {
if (model == null) {
return null;
}

model.remove(attr);
return model;
}

public static <M extends Model<M>> List<M> remove(List<M> modelList, String attr) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.remove(attr);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> remove(PageResult<M> pageResult, String attr) {
if (pageResult == null) {
return pageResult;
}

remove(pageResult.getRecords(), attr);
return pageResult;
}

public static <M extends Model<M>> M remove(M model, String... attrs) {
if (model == null) {
return model;
}

model.remove(attrs);
return model;
}

public static <M extends Model<M>> List<M> remove(List<M> modelList, String... attrs) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.remove(attrs);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> remove(
PageResult<M> pageResult, String... attrs) {
if (pageResult == null) {
return pageResult;
}

remove(pageResult.getRecords(), attrs);

return pageResult;
}

public static <M extends Model<M>> M keep(M model, String attr) {
if (model == null) {
return model;
}

model.keep(attr);
return model;
}

public static <M extends Model<M>> List<M> keep(List<M> modelList, String attr) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.keep(attr);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> keep(PageResult<M> pageResult, String attr) {
if (pageResult == null) {
return pageResult;
}

keep(pageResult.getRecords(), attr);

return pageResult;
}

public static <M extends Model<M>> M keep(M model, String... attrs) {
if (model == null) {
return model;
}

model.keep(attrs);

return model;
}

public static <M extends Model<M>> List<M> keep(List<M> modelList, String... attrs) {
if (CollUtil.isEmpty(modelList)) {
return modelList;
}

for (Model<M> model : modelList) {
model.keep(attrs);
}

return modelList;
}

public static <M extends Model<M>> PageResult<M> keep(PageResult<M> pageResult, String... attrs) {
if (pageResult == null) {
return pageResult;
}

keep(pageResult.getRecords(), attrs);

return pageResult;
}
}

AOP

Deform

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.goldsyear.solon.web.annotation;

import java.lang.annotation.*;

/**
* @author airhead
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Deform {
/**
* 保留的属性
*
* @return String[]
*/
String[] keep() default {};

/**
* 移除的属性
*
* @return String[]
*/
String[] remove() default {};
}

DeformInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package com.goldsyear.solon.web.interceptor;

import com.easy.query.solon.activerecord.Model;
import com.goldsyear.solon.web.annotation.Deform;
import com.goldsyear.solon.web.model.PageResult;
import com.goldsyear.solon.web.util.ModelUtils;
import java.util.Arrays;
import java.util.List;
import org.dromara.hutool.core.collection.CollUtil;
import org.noear.solon.core.aspect.Interceptor;
import org.noear.solon.core.aspect.Invocation;

/**
* @author airhead
*/
public class DeformInterceptor implements Interceptor {
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public Object doIntercept(Invocation inv) throws Throwable {
Object ret = inv.invoke();

Deform deform = inv.getMethodAnnotation(Deform.class);
// 不需要转换
if (deform == null) {
return ret;
}

if (ret instanceof Model) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
Model model = (Model) ret;
return ModelUtils.keep(model, deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
Model model = (Model) ret;
return ModelUtils.keep(model, deform.keep());
}
}

if (ret instanceof PageResult) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
PageResult pageResult = (PageResult) ret;
return ModelUtils.keep(pageResult.getRecords(), deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
PageResult pageResult = (PageResult) ret;
return ModelUtils.remove(pageResult.getRecords(), deform.keep());
}
}

if (ret instanceof List) {
if (CollUtil.isNotEmpty(Arrays.asList(deform.keep()))) {
List list = (List) ret;
return ModelUtils.keep(list, deform.keep());
}

if (CollUtil.isNotEmpty(Arrays.asList(deform.remove()))) {
List list = (List) ret;
return ModelUtils.remove(list, deform.keep());
}
}

return ret;
}
}

注册 DeformInterceptor

1
2
// 
context.beanInterceptorAdd(Deform.class, new DeformInterceptor());

使用

注意这里的是字段名,而不是类的属性名。

1
2
3
4
5
6
  @Deform(keep = {"id", "name"})
public List<CodeColumn> simpleListCodeColumn(CodeColumn codeColumn) {
List<CodeColumn> list = list(codeColumn);
// ModelUtils.keep(list, "id", "name");
return list;
}

序列化相关

因为 Model 和 Activerecord,渲染的时候并不是直接渲染成员,而是对应的 attr 成员下的内容,因此需要特殊处理。这里使用了 snack3, 如果使用了其他的json 框架,需要根据实际情况进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.goldsyear.solon.web;

import com.easy.query.solon.activerecord.Model;
import com.easy.query.solon.activerecord.Record;
import com.goldsyear.solon.web.annotation.Deform;
import com.goldsyear.solon.web.interceptor.DeformInterceptor;
import com.goldsyear.solon.web.model.ModelEncoder;
import com.goldsyear.solon.web.model.ModelFieldGetter;
import com.goldsyear.solon.web.model.RecordEncoder;
import com.goldsyear.solon.web.model.RecordFieldGetter;
import com.jfinal.kit.PathKit;
import com.jfinal.template.Engine;
import java.time.LocalDateTime;
import org.dromara.hutool.core.date.TimeUtil;
import org.noear.snack.core.Feature;
import org.noear.snack.core.Options;
import org.noear.solon.core.AppContext;
import org.noear.solon.core.Plugin;
import org.noear.solon.serialization.snack3.SnackActionExecutor;
import org.noear.solon.serialization.snack3.SnackRenderFactory;
import org.noear.solon.web.staticfiles.StaticMappings;
import org.noear.solon.web.staticfiles.repository.FileStaticRepository;

/**
* @author airhead
*/
public class EasyQueryWebPluginImp implements Plugin {
static boolean started = false;

@Override
public void start(AppContext context) {
if (started) {
return;
}

StaticMappings.add("/", new FileStaticRepository(PathKit.getWebRootPath()));

Engine.addFieldGetterToLast(new ModelFieldGetter());
Engine.addFieldGetterToLast(new RecordFieldGetter());

// 处理序列化
context.getBeanAsync(
SnackRenderFactory.class,
factory -> {
factory.addConvertor(LocalDateTime.class, TimeUtil::formatNormal);
factory.addEncoder(Model.class, new ModelEncoder<>());
factory.addEncoder(Record.class, new RecordEncoder<>());
});

// 处理反序列化,通过setter来设值。
context.getBeanAsync(
SnackActionExecutor.class,
snackActionExecutor -> {
Options config = snackActionExecutor.config();
config.add(Feature.UseSetter, Feature.DisableClassNameRead);
});

// 注册 DeformInterceptor
context.beanInterceptorAdd(Deform.class, new DeformInterceptor());

started = true;
}
}

Base Encoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package com.goldsyear.solon.web.model;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.dromara.hutool.core.collection.CollUtil;
import org.dromara.hutool.core.text.StrUtil;
import org.noear.snack.ONode;
import org.noear.solon.Solon;
import org.noear.solon.core.Props;

/**
* @author airhead
*/
public class BaseEncoder {
/** 对 Model 和 Record 的字段名进行转换的函数。例如转成驼峰形式对 oracle 支持更友好 */
protected static Function<String, String> fieldNameConverter = StrUtil::toCamelCase;

protected Props props = Solon.cfg().getProp("solon.serialization.json");

protected void encode(ONode node, Map<String, Object> map) {
if (CollUtil.isEmpty(map)) {
return;
}

Map<String, Object> newMap = new HashMap<>(map.size());
for (Map.Entry<String, Object> entry : map.entrySet()) {
String fieldName = entry.getKey();
Object value = entry.getValue();
String attrName = fieldName;
if (fieldNameConverter != null) {
attrName = fieldNameConverter.apply(fieldName);
}
newMap.put(attrName, value);
}

node.fill(newMap);
}
}

ModelEncoder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.goldsyear.solon.web.model;

import com.easy.query.solon.activerecord.CPI;
import com.easy.query.solon.activerecord.Model;
import org.noear.snack.ONode;
import org.noear.snack.core.NodeEncoder;

/**
* @author airhead
*/
public class ModelEncoder<T extends Model<?>> extends BaseEncoder implements NodeEncoder<T> {
@Override
public void encode(T data, ONode node) {
if (data == null) {
return;
}

encode(node, CPI.getAttrs(data));
}
}

RecordEncorder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.goldsyear.solon.web.model;

import com.easy.query.solon.activerecord.Record;
import org.noear.snack.ONode;
import org.noear.snack.core.NodeEncoder;

/**
* @author airhead
*/
public class RecordEncoder<T extends Record> extends BaseEncoder implements NodeEncoder<T> {

@Override
public void encode(T data, ONode node) {
if (data == null) {
return;
}

encode(node, data.getColumns());
}
}

获取 Model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected <T> T getBean(Class<T> type) {
T t = this.context().paramAsBean(type);
if (Model.class.isAssignableFrom(type)) {
if (t != null) {
Model<?> model = (Model<?>) t;
for (KeyValues<String> values : context().paramMap()) {
// 此处通过Map的方式设置
model.put(NamingCase.toUnderlineCase(values.getKey()), values.getFirstValue());
}

return t;
}
}

return t;
}

protected <T> T getModel(Class<T> type) {
return getModel(type, StrKit.firstCharToLowerCase(type.getSimpleName()));
}

protected <T> T getModel(Class<T> type, String name) {
MultiMap<String> paramMap = context().paramMap();
return PropsConverter.global().convert(new Props().addAll(paramMap).getProp(name), type);
}

小结

通过以上的扩展,easy-query 已经可以使用 ActiveRecord Model 的功能,可以用 SQL 模版的方式管理 SQL,并增强了ActiveRecord 的 SQL 能力,支持租户字段的过滤。同时借助 Solon 的 AOP 的能力,通过一个注解实现对结果结构的裁剪,减少转换类。

说明

在「Mac 上编译 Ragflow」的文章中只是简单的说到可以通过 Remote Development 来进入容器,可以通过修改Dockerfile的方式,增加映射卷的方式来开发。为什么要映射卷呢?这样可以保持 ragflow 原有的目录结构,同时保持 git 的代码管理,文件在卷中也不用担心因容器销毁等导致修改代码丢失的情况。

我今天完整的走通了开发的过程,重新整理开发部分的文档。为了保持完整,保留了 Remote Development 的部分说明。注意,前提是已经在 Mac 上编译完 ragflow 的 Dockerfile,并且可以正常运行。

开发

Remote Development

VS Code 安装 Remote Development 插件。它包含多个插件,其中 Dev Containters 可以连接到 Docker 容器作为开发环境,这样的好处是开发环境与部署环境一致。

img

当安装好 Remote Development,就可以通过 Remote Explorer 查看已经安装的容器,并通过点击对应容器的箭头(注意下图红色框的部分)。

img

进入到容器内部,选对应的目录,就可以打开 /ragflow,这样就能看到实际的代码了。

img

修改Dockerfile

主要是屏蔽编译过程和 probuction 步骤,增加 /raflow 卷的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# base stage
FROM docker.m.daocloud.io/ubuntu:22.04 AS base
USER root
SHELL ["/bin/bash", "-c"]

ARG NEED_MIRROR=0
ARG LIGHTEN=0
ENV LIGHTEN=${LIGHTEN}

WORKDIR /ragflow

# Copy models downloaded via download_deps.py
RUN mkdir -p /ragflow/rag/res/deepdoc /root/.ragflow
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/huggingface.co,target=/huggingface.co \
cp /huggingface.co/InfiniFlow/huqie/huqie.txt.trie /ragflow/rag/res/ && \
tar --exclude='.*' -cf - \
/huggingface.co/InfiniFlow/text_concat_xgb_v1.0 \
/huggingface.co/InfiniFlow/deepdoc \
| tar -xf - --strip-components=3 -C /ragflow/rag/res/deepdoc
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/huggingface.co,target=/huggingface.co \
if [ "$LIGHTEN" != "1" ]; then \
(tar -cf - \
/huggingface.co/BAAI/bge-large-zh-v1.5 \
/huggingface.co/BAAI/bge-reranker-v2-m3 \
/huggingface.co/maidalun1020/bce-embedding-base_v1 \
/huggingface.co/maidalun1020/bce-reranker-base_v1 \
| tar -xf - --strip-components=2 -C /root/.ragflow) \
fi

# https://github.com/chrismattmann/tika-python
# This is the only way to run python-tika without internet access. Without this set, the default is to check the tika version and pull latest every time from Apache.
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/,target=/deps \
cp -r /deps/nltk_data /root/ && \
cp /deps/tika-server-standard-3.0.0.jar /deps/tika-server-standard-3.0.0.jar.md5 /ragflow/ && \
cp /deps/cl100k_base.tiktoken /ragflow/9b5ad71b2ce5302211f9c61530b329a4922fc6a4

ENV TIKA_SERVER_JAR="file:///ragflow/tika-server-standard-3.0.0.jar"
ENV DEBIAN_FRONTEND=noninteractive

# Setup apt
# Python package and implicit dependencies:
# opencv-python: libglib2.0-0 libglx-mesa0 libgl1
# aspose-slides: pkg-config libicu-dev libgdiplus libssl1.1_1.1.1f-1ubuntu2_amd64.deb
# python-pptx: default-jdk tika-server-standard-3.0.0.jar
# selenium: libatk-bridge2.0-0 chrome-linux64-121-0-6167-85
# Building C extensions: libpython3-dev libgtk-4-1 libnss3 xdg-utils libgbm-dev
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
if [ "$NEED_MIRROR" == "1" ]; then \
sed -i 's|http://archive.ubuntu.com|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list; \
fi; \
rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \
chmod 1777 /tmp && \
apt update && \
apt --no-install-recommends install -y ca-certificates && \
apt update && \
apt install -y libglib2.0-0 libglx-mesa0 libgl1 && \
apt install -y pkg-config libicu-dev libgdiplus && \
apt install -y default-jdk && \
apt install -y libatk-bridge2.0-0 && \
apt install -y libpython3-dev libgtk-4-1 libnss3 xdg-utils libgbm-dev && \
apt install -y libjemalloc-dev && \
apt install -y python3-pip pipx nginx unzip curl wget git vim less

RUN if [ "$NEED_MIRROR" == "1" ]; then \
pip3 config set global.index-url https://mirrors.aliyun.com/pypi/simple && \
pip3 config set global.trusted-host mirrors.aliyun.com; \
mkdir -p /etc/uv && \
echo "[[index]]" > /etc/uv/uv.toml && \
echo 'url = "https://mirrors.aliyun.com/pypi/simple"' >> /etc/uv/uv.toml && \
echo "default = true" >> /etc/uv/uv.toml; \
fi; \
pipx install uv

ENV PYTHONDONTWRITEBYTECODE=1 DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
ENV PATH=/root/.local/bin:$PATH

# nodejs 12.22 on Ubuntu 22.04 is too old
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt purge -y nodejs npm cargo && \
apt autoremove -y && \
apt update && \
apt install -y nodejs

# A modern version of cargo is needed for the latest version of the Rust compiler.
RUN apt update && apt install -y curl build-essential \
&& if [ "$NEED_MIRROR" == "1" ]; then \
# Use TUNA mirrors for rustup/rust dist files
export RUSTUP_DIST_SERVER="https://mirrors.tuna.tsinghua.edu.cn/rustup"; \
export RUSTUP_UPDATE_ROOT="https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup"; \
echo "Using TUNA mirrors for Rustup."; \
fi; \
# Force curl to use HTTP/1.1
curl --proto '=https' --tlsv1.2 --http1.1 -sSf https://sh.rustup.rs | bash -s -- -y --profile minimal \
&& echo 'export PATH="/root/.cargo/bin:${PATH}"' >> /root/.bashrc

ENV PATH="/root/.cargo/bin:${PATH}"

RUN cargo --version && rustc --version

# Add msssql ODBC driver
# macOS ARM64 environment, install msodbcsql18.
# general x86_64 environment, install msodbcsql17.
RUN --mount=type=cache,id=ragflow_apt,target=/var/cache/apt,sharing=locked \
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \
curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list > /etc/apt/sources.list.d/mssql-release.list && \
apt update && \
arch="$(uname -m)"; \
if [ "$arch" = "arm64" ] || [ "$arch" = "aarch64" ]; then \
# ARM64 (macOS/Apple Silicon or Linux aarch64)
ACCEPT_EULA=Y apt install -y unixodbc-dev msodbcsql18; \
else \
# x86_64 or others
ACCEPT_EULA=Y apt install -y unixodbc-dev msodbcsql17; \
fi || \
{ echo "Failed to install ODBC driver"; exit 1; }

# Add dependencies of selenium
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/chrome-linux64-121-0-6167-85,target=/chrome-linux64.zip \
unzip /chrome-linux64.zip && \
mv chrome-linux64 /opt/chrome && \
ln -s /opt/chrome/chrome /usr/local/bin/
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/chromedriver-linux64-121-0-6167-85,target=/chromedriver-linux64.zip \
unzip -j /chromedriver-linux64.zip chromedriver-linux64/chromedriver && \
mv chromedriver /usr/local/bin/ && \
rm -f /usr/bin/google-chrome

# https://forum.aspose.com/t/aspose-slides-for-net-no-usable-version-of-libssl-found-with-linux-server/271344/13
# aspose-slides on linux/arm64 is unavailable
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/,target=/deps \
if [ "$(uname -m)" = "x86_64" ]; then \
dpkg -i /deps/libssl1.1_1.1.1f-1ubuntu2_amd64.deb; \
elif [ "$(uname -m)" = "aarch64" ]; then \
dpkg -i /deps/libssl1.1_1.1.1f-1ubuntu2_arm64.deb; \
fi

# builder stage
FROM base AS builder
USER root

WORKDIR /ragflow

# install dependencies from uv.lock file
# COPY pyproject.toml uv.lock ./

# https://github.com/astral-sh/uv/issues/10462
# uv records index url into uv.lock but doesn't failover among multiple indexes
# RUN --mount=type=cache,id=ragflow_uv,target=/root/.cache/uv,sharing=locked \
# if [ "$NEED_MIRROR" == "1" ]; then \
# sed -i 's|pypi.org|mirrors.aliyun.com/pypi|g' uv.lock; \
# else \
# sed -i 's|mirrors.aliyun.com/pypi|pypi.org|g' uv.lock; \
# fi; \
# if [ "$LIGHTEN" == "1" ]; then \
# uv sync --python 3.10 --frozen; \
# else \
# uv sync --python 3.10 --frozen --all-extras; \
# fi

# COPY web web
# COPY docs docs
# RUN --mount=type=cache,id=ragflow_npm,target=/root/.npm,sharing=locked \
# cd web && npm install && npm run build

COPY .git /ragflow/.git

RUN version_info=$(git describe --tags --match=v* --first-parent --always); \
if [ "$LIGHTEN" == "1" ]; then \
version_info="$version_info slim"; \
else \
version_info="$version_info full"; \
fi; \
echo "RAGFlow version: $version_info"; \
echo $version_info > /ragflow/VERSION

# production stage
# FROM base AS production
# USER root

# WORKDIR /ragflow

# Copy Python environment and packages
ENV VIRTUAL_ENV=/ragflow/.venv
# COPY --from=builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"

ENV PYTHONPATH=/ragflow/

# COPY web web
# COPY api api
# COPY conf conf
# COPY deepdoc deepdoc
# COPY rag rag
# COPY agent agent
# COPY graphrag graphrag
# COPY agentic_reasoning agentic_reasoning
# COPY pyproject.toml uv.lock ./

# COPY docker/service_conf.yaml.template ./conf/service_conf.yaml.template
# COPY docker/entrypoint.sh docker/entrypoint-parser.sh ./
# RUN chmod +x ./entrypoint*.sh

# Copy compiled web pages
# COPY --from=builder /ragflow/web/dist /ragflow/web/dist

# COPY --from=builder /ragflow/VERSION /ragflow/VERSION

VOLUME /ragflow

ENTRYPOINT ["./entrypoint.sh"]

重新编译镜像

1
docker build --build-arg LIGHTEN=1 --build-arg NEED_MIRROR=1 -f Dockerfile -t infiniflow/ragflow:nightly-slim .

修改docker-compose-macos.yml

这里设置本地的 ragflow 为镜像的/ragflow卷的,映射目录。这的好处就是还可以保留 git 的代码管理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
include:
- ./docker-compose-base.yml

services:
ragflow:
depends_on:
mysql:
condition: service_healthy
build:
context: ../
dockerfile: Dockerfile
container_name: ragflow-server
ports:
- ${SVR_HTTP_PORT}:9380
- 80:80
- 443:443
volumes:
- ./ragflow-logs:/ragflow/logs
- ./nginx/ragflow.conf:/etc/nginx/conf.d/ragflow.conf
- ./nginx/proxy.conf:/etc/nginx/proxy.conf
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ../:/ragflow
env_file: .env
environment:
- TZ=${TIMEZONE}
- HF_ENDPOINT=${HF_ENDPOINT}
- MACOS=${MACOS:-1}
- LIGHTEN=${LIGHTEN:-1}
networks:
- ragflow
restart: on-failure
# https://docs.docker.com/engine/daemon/prometheus/#create-a-prometheus-configuration
# If you're using Docker Desktop, the --add-host flag is optional. This flag makes sure that the host's internal IP gets exposed to the Prometheus container.
extra_hosts:
- "host.docker.internal:host-gateway"

因为增加了 ragflow 的卷映射,这里注意拷贝 docker/service_conf.yaml.template 到 ragflow/conf 目录下,docker/entrypoint.sh docker/entrypoint-parser.sh 拷贝到 raflow 目录下,避免程序无法启动。

1
2
cd docker
$ docker compose -f docker-compose-macos.yml up -d

修改entrypoint.sh

增加 --debug 支持调试信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/bash

# replace env variables in the service_conf.yaml file
rm -rf /ragflow/conf/service_conf.yaml
while IFS= read -r line || [[ -n "$line" ]]; do
# Use eval to interpret the variable with default values
eval "echo \"$line\"" >> /ragflow/conf/service_conf.yaml
done < /ragflow/conf/service_conf.yaml.template

/usr/sbin/nginx

export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/

PY=python3
if [[ -z "$WS" || $WS -lt 1 ]]; then
WS=1
fi

function task_exe(){
JEMALLOC_PATH=$(pkg-config --variable=libdir jemalloc)/libjemalloc.so
while [ 1 -eq 1 ];do
LD_PRELOAD=$JEMALLOC_PATH $PY rag/svr/task_executor.py $1;
done
}

for ((i=0;i<WS;i++))
do
task_exe $i &
done

while [ 1 -eq 1 ];do
$PY api/ragflow_server.py --debug
done

wait;

编译

正常情况下,这个时候服务是无法访问的,但容器已经启动,可以通过 dev containers 来访问容器。进入容器后编译前端和重新安装 ragflow 的 python 依赖。

前端

1
2
3
cd web
npm install
npm run build

后端

重新获取依赖

1
uv sync --python 3.10 --frozen;

验证

目录结构一致

img

修改下代码,比如api/apps/user_app.py。

img

我们可以看到修改的代码已经被执行。

img

小结

编程很多时候的问题是部署开发环境的问题,现在有了 docker 已经可以开发部署简单多了。但因为众所周知的原因,你还需要掌握科学上网的魔法。

这次部署 ragflow 环境下来,发现硬盘太小是个问题,模型或者镜像都要确定哪些没用了及时删除。

说明

一开始尝试按源码启动的方式(https://ragflow.io/docs/dev/launch_ragflow_from_source),直接运行 Ragflow,但是在安装 Python 依赖的时候就报错了。于是尝试使用 Docker 的方式运行(https://ragflow.io/docs/dev/build_docker_image)。为什么呢?因为目前官方提供的所有 Docker 镜像均基于 x86 架构构建,并不提供基于 ARM64 的 Docker 镜像。

我这里使用 OrbStack 工具来管理容器。

编译

步骤

  1. 安装uv
1
2
3
4
# On macOS and Linux.
curl -LsSf https://astral.sh/uv/install.sh | sh

# 根据提示,执行 source 命令,或者重开 termial
  1. 获取源码编译镜像
1
2
3
4
5
git clone https://github.com/infiniflow/ragflow.git
cd ragflow/
uv run download_deps.py
docker build -f Dockerfile.deps -t infiniflow/ragflow_deps .
docker build --build-arg LIGHTEN=1 -f Dockerfile -t infiniflow/ragflow:nightly-slim .

其中 download_deps.py会下载一些基础依赖和模型,可以根据自己的实际情况调整模型的下载,比如是否下载模型,模型的下载地址等(未经完全验证是否可以屏蔽模型)。

以下模型下载的关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 镜像列表
repos = [
"InfiniFlow/text_concat_xgb_v1.0",
"InfiniFlow/deepdoc",
"InfiniFlow/huqie",
"BAAI/bge-large-zh-v1.5",
"BAAI/bge-reranker-v2-m3",
"maidalun1020/bce-embedding-base_v1",
"maidalun1020/bce-reranker-base_v1",
]

# 下载镜像
for repo_id in repos:
print(f"Downloading huggingface repo {repo_id}...")
download_model(repo_id)

注意事项

特别重要的是,编译镜像的过程,注意开启全局的科学上网。主要可能碰到的问题,比如组件下载不成功,组件下载不完整等等。

如果组件下载不成功的话,重新执行一遍就好了。

我就在最后一步 docker build 的时候出现问题,看提示就是需要安装的包出错了,在 ragflow 目录下找到对应下载的文件删掉,然后重新执行 uv run download_deps.py

编译的过程时间可能比较长,需要耐心等待。

运行

需要远行 Ragflow 还需要做些配置。打开 docker/.env,找到 RAGFLOW_IMAGE的配置,把镜像地址修改成 infiniflow/ragflow:nightly-slim

接下来通过docker compose 启动服务。

1
2
cd docker
$ docker compose -f docker-compose-macos.yml up -d

耐心等待相关的服务启动,然后使用http://127.0.0.1 访问。

默认没有用户,直接注册一个就可以,第一个用户会成为管理员。

img

问题

自己碰的的问题,是添加模型的时候出错。看了ragflow 群里的问题,这个问题也蛮多人问的。

ragflow 提供的 docker compose 使用的是 ragflow 的对应的网桥就是 docker_ragflow,也可以通过如下命令查看。

1
2
3
4
5
6
7
docker network ls

# 结果
881b4fc04bf4 bridge bridge local
67e9e0de36bc docker_ragflow bridge local
4e76407faa32 host host local
d611379d4316 none null local

找到了网桥,我们可以通过 docker inspect 查看具体的ip,找到 Gateway 的部分就是宿主机的 ip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
docker inspect docker_ragflow

# Gateway
[
{
"Name": "docker_ragflow",
"Id": "67e9e0de36bc646402f69919aaafa30d663f27eb66a6e63bfaf8f75fc59c8a01",
"Created": "2025-03-14T14:19:59.789673667+08:00",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "192.168.97.0/24",
"Gateway": "192.168.97.1"
}
]

找到了正确的地址,但还是不能通过宿主机地址访问,那么检查 ollama 的配置,是否导出对应的变量。

1
2
3
4
5
export OLLAMA_ORIGINS="*"
export OLLAMA_HOST=0.0.0.0:11434

# 如果没有添加,需要补上
# source .zshrc

如果没有添加,重新添加后重启 ollama。然后通过本地 IP,而不是 127.0.0.1 或者 localhost 访问,是否正常。

img

如果此时提示网页无法访问,这时应该运行 ollama serve,开启 ollama 的服务。

1
2
3
4
ollama serve

# 可以看到类似的提示信息。
2025/03/14 14:48:19 routes.go:1225: INFO server config env="map[HTTPS_PROXY: HTTP_PROXY: NO_PROXY: OLLAMA_CONTEXT_LENGTH:2048 OLLAMA_DEBUG:false OLLAMA_FLASH_ATTENTION:false OLLAMA_GPU_OVERHEAD:0 OLLAMA_HOST:http://0.0.0.0:11434 OLLAMA_KEEP_ALIVE:5m0s OLLAMA_KV_CACHE_TYPE: OLLAMA_LLM_LIBRARY: OLLAMA_LOAD_TIMEOUT:5m0s OLLAMA_MAX_LOADED_MODELS:0 OLLAMA_MAX_QUEUE:512 OLLAMA_MODELS:/Users/airhead/.ollama/models OLLAMA_MULTIUSER_CACHE:false OLLAMA_NEW_ENGINE:false OLLAMA_NOHISTORY:false OLLAMA_NOPRUNE:false OLLAMA_NUM_PARALLEL:0 OLLAMA_ORIGINS:[* http://localhost https://localhost http://localhost:* https://localhost:* http://127.0.0.1 https://127.0.0.1 http://127.0.0.1:* https://127.0.0.1:* http://0.0.0.0 https://0.0.0.0 http://0.0.0.0:* https://0.0.0.0:* app://* file://* tauri://* vscode-webview://* vscode-file://*] OLLAMA_SCHED_SPREAD:false http_proxy: https_proxy: no_proxy:]"

填写网桥的 ip 之后还是无法访问,那需要确定实际的网桥 ip 是否真的存在。

1
ipconfig grep | 192.168.7.1

如果没有结果就说明 ip 没有真正起来,导致 ip 无法访问。接着通过 ifconfig,找到可访问的 ip,比如我这里实际可访问的网桥是 bridge102(这里暂时不确定为什么。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
ipconfig

bridge102: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether fa:4d:89:d7:be:66
inet 198.19.249.3 netmask 0xfffffe00 broadcast 198.19.249.255
inet6 fe80::f84d:89ff:fed7:be66%bridge102 prefixlen 64 scopeid 0x1e
inet6 fd07:b51a:cc66:0:a617:db5e:ab7:e9f1 prefixlen 64
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x0
member: vmenet3 flags=10003<LEARNING,DISCOVER,CSUM>
ifmaxaddr 0 port 29 priority 0 path cost 0
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active

bridge100: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether fa:4d:89:d7:be:64
inet 192.168.97.0 netmask 0xffffff00 broadcast 192.168.97.255
inet6 fe80::f84d:89ff:fed7:be64%bridge100 prefixlen 64 scopeid 0x18
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x0
member: vmenet0 flags=10003<LEARNING,DISCOVER,CSUM>
ifmaxaddr 0 port 23 priority 0 path cost 0
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active

ifconfig |grep 198.19.249.3
inet 198.19.249.3 netmask 0xfffffe00 broadcast 198.19.249.255

之后使用了 198.19.249.3 这个ip 可以正常访问,配置 llm 也正常。

img

为什么不用本机 ip 而用 docker 网桥的 ip 呢?因为是笔记本,通过 Wi-Fi 获取的 ip 可能变化,而网桥 ip 是稳定的。

开发

VS Code 安装 Remote Development 插件。它包含多个插件,其中 Dev Containters 可以连接到 Docker 容器作为开发环境,这样的好处是开发环境与部署环境一致。

img

当安装好 Remote Development,就可以通过 Remote Explorer 查看已经安装的容器,并通过点击对应容器的箭头(注意下图红色框的部分)。

img

进入到容器内部,选对应的目录,就可以打开 /ragflow,这样就能看到实际的代码了。

img

其他

  1. 还需要调整 Dockerfile 方便挂载本地的源码位置,这样方便通过外部的 Git 管理开发代码。
  2. 在配置 LLM 模型的使用有个支持的最大 Token 数,这个值应该怎么填呢?通过 ollama show查看对应模型的信息,其中 context length 就是模型的最大 Token 数了。

img

1
2
3
4
5
6
7
8
9
ollama show deepseek-r1:7b

#
Model
architecture qwen2
parameters 7.6B
context length 131072
embedding length 3584
quantization Q4_K_M
  1. 因为 ollama 暂时不支持重排,需要安装 xinterface。目前通过容器安装 xinterface,可能镜像或者配置选错了,导致 xinterface 的镜像无法启动。
  2. 因为 OrbStack 也可以运行虚拟机,理论上来说,可以通过安装 Ubuntu 虚拟机的方式,照着 Dockerfile 文件安装相关的依赖。