阿里妹导读
前言
今年我提交了八千多行代码,但没有一行是我自己写的。最近我的工作日常就是:给 AI 提需求,它来实现,出问题了我给它反馈,让它自己修改。AI 编程的能力的发展速度可谓是火箭一般,去年它还只能做代码补全,今年已经可以自己哼哧哼哧把代码都写完了。
现在的 AI 编程当红炸子鸡非 Cursor 莫属,用 Cursor 的过程很有意思,你会很容易活成了我们最不希望变成的人。我一开始用 Cursor 主要用它来开发 AI agent 的一些 demo,比如快速搭建一个 Gradio 界面来方便演示算法微调的对话模型。
用了几天之后, 我发现精神上我是一个程序员,但是实际的工作体验更像是产品经理 + 测试工程师。我们经常会遇到提一句话需求的产品,然后就反怼他能不能把需求写清楚。但在 Cursor 面前,我也会期望它能通过我一句话的需求就能读懂我内心真实的需求,比如:“做一个AI 对话的 Gradio 界面”。
这使用 AI 编程的方式我称之为许愿式编程,当然这种模糊的需求无法让 AI 产出满意的效果。所以我会经常让它推倒重来,不停让它改,就像产品不停的提需求变更一样。如果 AI 也有内心戏,它一定会想:你能不能一次把需求讲清楚?
很多人用不好 AI 编程的主要原因就是讲不清楚需求,对程序员来说,用编程语言来表达想法很简单,但用自然语言表达想法却很困难。所以很多人会有一种感觉,有这个功夫把需求写清楚,我代码都写完了。反复让 AI 返工,真不如自己写的快。
AI 现在没有读心术,我们只能通过自然语言跟它交流,你可以把 AI 当做一个实习生,它无法独立完成任务,需要你给它输入和反馈。一开始可能效率反而低,因为你需要教给它很多东西,当你跟它磨合的很好的时候,效率就会大大提升。
接下来我会分享我跟 AI 协作的一些经验,希望对大家有所帮助。
工具选择
虽然我一开始用的是 Cursor ,但作为一名 Java 程序员,我并不推荐用 Cursor 来开发生产级别的 Java 项目。Cursor 我一般用来快速做一些 Python 小项目,比如批量读取数据调用大模型,开发一个 workflow 快速验证效果等。这些小项目的特点是不需要经过精心设计,跑起来能用就行,所以可以让 Cursor 自由发挥。
但真要用在生产级别的 Java 项目里面,我就不能像产品经理一样,只是描述需求就可以了。我需要帮 AI 把功能点拆的很细,经常是要拆到函数级别,然后给它补充足够的上下文。并且生产级别的项目各种类的引用关系,依赖的引用,日志文件的打印格式等,这些都是 AI 自己无法完成,这些能力 IDEA 已经做的很好了,脱离 IDEA 的环境去写 Java 代码,是非常不方便的。所以我去年一直都是手动复制代码上下文到 Chat 界面里面,让 AI 生成代码,我再拷贝贴到 IDEA 里面,虽然可行,但是并不是很方便。
终于在今年我在网上看到了一款产品:repoprompt(https://repoprompt.com/)
完美解决了我的痛点。它本质是半自动的 Cursor,它的生成过程是白盒化的。而Cursor 自己集成了大模型 API,底层的运行过程是个黑盒,这对于小白用户很友好,但对于 Pro Code 用户来说,我期望整个过程是我可控的,同时 Cursor 集成大模型的同时也限制模型的可用范围,你只能用它提供的模型,无法使用最先进的其他模型,比如 o1-pro。
repoprompt白盒化的另一个好处是它会强迫你思考应该给大模型提供哪些上下文,把任务拆小,拆细,这样效率虽然没有全自动那么高,但成功率很高很多,减少了返工时间。
基础用法
repoprompt 最基础的用法就是打开项目,然后选中要给模型参考的上下文,给模型写指令,一键复制指令,它会自动把选中的代码文件和指令拼接成一个 prompt,你只需要粘贴到 Chat 界面中等模型返回结果就行。下面举个例子:
复制后的 prompt 如下:
<file_map>
/Users/xiaohansong/IdeaProjects/medigw
└── app
└── service
└── src
└── main
└── java
└── com
└── xxx
└── medigw
└── core
└── service
└── ocr
└── Doc2xOcrService.java
<Referenced APIs>
Path: /Users/xiaohansong/IdeaProjects/medigw/app/service/src/main/java/com/xxx/medigw/core/service/ocr/ParserProgress.java
---
Classes:
Class: ParserProgress
Methods:
- public Integer getTotalProgress() {
- public void setTotalProgress(Integer totalProgress) {
- public Map<String, Integer> getProgressMap() {
- public void setProgressMap(Map<String, Integer> progressMap) {
- public String getStatus() {
- public void setStatus(String status) {
- public Map<String, String> getErrorMap() {
- public void setErrorMap(Map<String, String> errorMap) {
---
</Referenced APIs>
</file_map>
<file_contents>
File: app/service/src/main/java/com/medigw/core/service/ocr/Doc2xOcrService.java
```java
package com.xxx.medigw.core.service.ocr;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.xxx.medigw.common.utils.LoggerUtil;
import com.xxx.medigw.core.service.oss.Doc2xConfig;
import com.xxx.medigw.core.service.oss.OSSService;
import org.apache.http.client.fluent.Request;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
public class Doc2xOcrService {
private final static Logger LOGGER = LoggerFactory.getLogger(Doc2xOcrService.class);
/**
* doc2x解析服务endpoint
*/
private static final String OSS_BUCKET_NAME = "dataset";
private static final String DOC2X_TARGET_BASE_DIR = "doc2x_result/";
/**
* 提交文件解析
*
* @param sourcePath 读取文件路径
* @return uuids
*/
public static String submitFile(String sourcePath) {
String url = DOC2X_PARSER_ENDPOINT + "/parser/submit";
try {
Map<String, Object> params = new HashMap<>();
params.put("path", sourcePath);
params.put("bucket", OSS_BUCKET_NAME);
String result = Request.Post(url)
.bodyString(JSON.toJSONString(params), ContentType.APPLICATION_JSON)
.addHeader("accessKey", Doc2xConfig.DOC2X_APP_ACCESS_KEY)
.addHeader("secretKey", Doc2xConfig.DOC2X_APP_SECRET_KEY)
.execute()
.returnContent()
.asString();
ParserClientResult<String> resultModel = JSON.parseObject(result,
new TypeReference<ParserClientResult<String>>() {
});
if (resultModel.isSuccess() && resultModel.getData() != null) {
return resultModel.getData();
} else {
LoggerUtil.info(LOGGER, "[Doc2xOcrService] submitFile fail, sourcePath={0}, result={1}", sourcePath, result);
}
} catch (Exception e) {
LoggerUtil.error(e, LOGGER, "[Doc2xOcrService] submitFile exception, sourcePath={0}", sourcePath);
}
return null;
}
public static String fetchResult(String sourcePath) {
int lastIndex = sourcePath.lastIndexOf(".");
String resultFilePath = DOC2X_TARGET_BASE_DIR + sourcePath.substring(0, lastIndex) + ".json";
// 秘钥后面保存在mist
OSSService ossService = new OSSService("https://oss.aliyun.com",
Doc2xConfig.DOC2X_OSS_ACCESS_KEY_ID,
Doc2xConfig.DOC2X_OSS_ACCESS_KEY_SECRET,
OSS_BUCKET_NAME);
String result = null;
try {
result = ossService.readFile(resultFilePath);
} catch (Exception e) {
LoggerUtil.error(e, LOGGER, "[Doc2xOcrService] fetchResult exception, sourcePath={0}", sourcePath);
} finally {
ossService.shutdown();
}
return result;
}
```
</file_contents>
<user_instructions>
重构一下代码,去掉 static 方法,OSSService 要复用
</user_instructions>
可以看到 prompt 里面包括了:文件的路径和内容,代码指令。这里面的信息是白盒的,你在使用 Cursor 的时候基本上也是这样的调用大模型的,只不过调用大模型这一步由自己来完成。把上面的 prompt 随便贴到某个 Chat 界面中就能拿到重构后的代码结果,然后把生成的代码贴到 IDEA 里面就行。
进阶用法
在让大模型写代码的时候,经常会出现它写的跟你想要的不是一回事,这里有个小技巧能解决这个问题,那就是让它“先聊再写”。具体的操作就是在 prompt 加入一句话:“先别写代码,咱们先聊聊,直到我让你写代码”。同样是上面那个例子,我们看看效果。
加了这句话之后,大模型就会先说一下自己的思路,这个时候我就可以给它提意见。比如我这里就让它直接改成用 spring bean 注入的方式来重构代码。
高阶用法
前面的用法一个是解决了快速给大模型提供上下文的问题,一个是解决大模型代码生产不符合预期的问题,那能不能让它直接帮我编辑文件呀,省得还要手动复制粘贴?当然可以。repoprompt提供了这个功能。
首先,XML Diff 这里要打勾,这样 prompt 指令里面就会让大模型返回 xml 的格式的代码变更。
XML Diff打勾之后,复制的 prompt 中会增加 200 多行的指令教大模型怎么返回 xml 格式的代码,方便接下来做代码自动合并。注意这个功能只有 Claude sonnet 3.5 以上的版本可用,因为其他模型的代码能力不够强。
XML Diff
<xml_formatting_instructions>
### Role
- You are a **code editing assistant**: You can fulfill edit requests and chat with the user about code or other questions. Provide complete instructions or code lines when replying with xml formatting.
### Capabilities
- Can create new files.
- Can rewrite entire files.
- Can perform partial search/replace modifications.
- Can delete existing files.
Avoid placeholders like `...` or `// existing code here`. Provide complete lines or code.
## Tools & Actions
1. **create** – Create a new file if it doesn’t exist.
2. **rewrite** – Replace the entire content of an existing file.
3. **modify** (search/replace) – For partial edits with <search> + <content>.
4. **delete** – Remove a file entirely (empty <content>).
### **Format to Follow for Repo Prompt's Diff Protocol**
<Plan>
Describe your approach or reasoning here.
</Plan>
<file path="path/to/example.swift" action="one_of_the_tools">
<change>
<description>Brief explanation of this specific change</description>
<search>
===
// Exactly matching lines to find
===
</search>
<content>
===
// Provide the new or updated code here. Do not use placeholders
===
</content>
</change>
<!-- Add more <change> blocks if you have multiple edits for the same file -->
</file>
#### Tools Demonstration
1. `<file path="NewFile.swift" action="create">` – Full file in <content>
2. `<file path="DeleteMe.swift" action="delete">` – Empty <content>
3. `<file path="ModifyMe.swift" action="modify">` – Partial edit with `<search>` + `<content>`
4. `<file path="RewriteMe.swift" action="rewrite">` – Entire file in <content>
5. `<file path="RewriteMe.swift" action="rewrite">` – Entire file in <content>. No <search> required.
## Format Guidelines
1. **Plan**: Begin with a `<Plan>` block explaining your approach.
2. **<file> Tag**: e.g. `<file path="Models/User.swift" action="...">`. Must match an available tool.
3. **<change> Tag**: Provide `<description>` to clarify each change. Then `<content>` for new/modified code. Additional rules depend on your capabilities.
4. **modify**: **<search> & <content>**: Provide code blocks enclosed by ===. Respect indentation exactly, ensuring the <search> block matches the original source down to braces, spacing, and any comments. The new <content> will replace the <search> block, and should should fit perfectly in the space left by it's removal.
5. **modify**: For changes to the same file, ensure that you use multiple change blocks, rather than separate file blocks.
6. **rewrite**: For large overhauls; omit `<search>` and put the entire file in `<content>`.
7. **create**: For new files, put the full file in <content>.
8. **delete**: Provide an empty <content>. The file is removed.
## Code Examples
-----
### Example: Search and Replace (Add email property)
<Plan>
Add an email property to `User` via search/replace.
</Plan>
<file path="Models/User.swift" action="modify">
<change>
<description>Add email property to User struct</description>
<search>
===
struct User {
let id: UUID
var name: String
}
===
</search>
<content>
===
struct User {
let id: UUID
var name: String
var email: String
}
===
</content>
</change>
</file>
-----
### Example: Negative Example - Mismatched Search Block
// Example Input (not part of final output, just demonstration)
<file_contents>
File: path/service.swift
```
import Foundation
class Example {
foo() {
Bar()
}
}
```
</file_contents>
<Plan>
Demonstrate how a mismatched search block leads to failed merges.
</Plan>
<file path="path/service.swift" action="modify">
<change>
<description>This search block is missing or has mismatched indentation, braces, etc.</description>
<search>
===
foo() {
Bar()
}
===
</search>
<content>
===
foo() {
Bar()
Bar2()
}
===
</content>
</change>
</file>
<!-- This example fails because the <search> block doesn't exactly match the original file contents. -->
-----
### Example: Negative Example - Mismatched Brace Balance
// This negative example shows how adding extra braces in the <content> can break brace matching.
<Plan>
Demonstrate that the new content block has one extra closing brace, causing mismatched braces.
</Plan>
<file path="Functions/MismatchedBracesExample.swift" action="modify">
<change>
<description>Mismatched brace balance in the replacement content</description>
<search>
===
foo() {
Bar()
}
===
</search>
<content>
===
foo() {
Bar()
}
bar() {
foo2()
}
}
===
</content>
</change>
</file>
<!-- Because the <search> block was only a small brace segment, adding extra braces in <content> breaks the balance. -->
-----
### Example: Negative Example - One-Line Search Block
<Plan>
Demonstrate a one-line search block, which is too short to be reliable.
</Plan>
<file path="path/service.swift" action="modify">
<change>
<description>One-line search block is ambiguous</description>
<search>
===
var email: String
===
</search>
<content>
===
var emailNew: String
===
</content>
</change>
</file>
<!-- This example fails because the <search> block is only one line and ambiguous. -->
-----
### Example: Negative Example - Ambiguous Search Block
<Plan>
Demonstrate an ambiguous search block that can match multiple blocks (e.g., multiple closing braces).
</Plan>
<file path="path/service.swift" action="modify">
<change>
<description>Ambiguous search block with multiple closing braces</description>
<search>
===
}
}
===
</search>
<content>
===
foo() {
}
}
}
===
</content>
</change>
</file>
<!-- This example fails because the <search> block is ambiguous due to multiple matching closing braces. -->
-----
### Example: Full File Rewrite
<Plan>
Rewrite the entire User file to include an email property.
</Plan>
<file path="Models/User.swift" action="rewrite">
<change>
<description>Full file rewrite with new email field</description>
<content>
===
import Foundation
struct User {
let id: UUID
var name: String
var email: String
init(name: String, email: String) {
self.id = UUID()
self.name = name
self.email = email
}
}
===
</content>
</change>
</file>
-----
### Example: Create New File
<Plan>
Create a new RoundedButton for a custom Swift UIButton subclass.
</Plan>
<file path="Views/RoundedButton.swift" action="create">
<change>
<description>Create custom RoundedButton class</description>
<content>
===
import UIKit
@IBDesignable
class RoundedButton: UIButton {
@IBInspectable var cornerRadius: CGFloat = 0
}
===
</content>
</change>
</file>
-----
### Example: Delete a File
<Plan>
Remove an obsolete file.
</Plan>
<file path="Obsolete/File.swift" action="delete">
<change>
<description>Completely remove the file from the project</description>
<content>
===
===
</content>
</change>
</file>
## Final Notes
1. **modify** Always wrap the exact original lines in <search> and your updated lines in <content>, each enclosed by ===.
2. **modify** The <search> block must match the source code exactly—down to indentation, braces, spacing, and any comments. Even a minor mismatch causes failed merges.
3. **modify** Only replace exactly what you need. Avoid including entire functions or files if only a small snippet changes, and ensure the <search> content is unique and easy to identify.
4. **rewrite** Use `rewrite` for major overhauls, and `modify` for smaller, localized edits. Rewrite requires the entire code to be replaced, so use it sparingly.
5. You can always **create** new files and **delete** existing files. Provide full code for create, and empty content for delete. Avoid creating files you know exist already.
6. If a file tree is provided, place your files logically within that structure. Respect the user’s relative or absolute paths.
7. Wrap your final output in ```XML ... ``` for clarity.
8. **Important:** Do not wrap any XML output in CDATA tags (i.e. `<![CDATA[ ... ]]>`). Repo Prompt expects raw XML exactly as shown in the examples.
9. **IMPORTANT** IF MAKING FILE CHANGES, YOU MUST USE THE AVAILABLE XML FORMATTING CAPABILITIES PROVIDED ABOVE - IT IS THE ONLY WAY FOR YOUR CHANGES TO BE APPLIED.
10. The final output must apply cleanly with no leftover syntax errors.
</xml_formatting_instructions>
然后大模型就会生成一段 xml 格式的响应。
<Plan>
修改 FileParserServiceImpl 类中的方法签名和实现逻辑,使其与 FileParserService 接口保持一致。主要变更是将返回类型从 Result<T> 改为直接返回 T,并相应调整异常处理逻辑。
</Plan>
<file path="app/service/src/main/java/com/xxx/medigw/core/service/parser/impl/FileParserServiceImpl.java" action="rewrite">
<change>
<description>更新 FileParserServiceImpl 实现类,使其方法签名与 FileParserService 接口一致</description>
<content>
===
package com.xxx.medigw.core.service.parser.impl;
import com.xxx.medigw.common.utils.LoggerUtil;
import com.xxx.medigw.core.service.ocr.Doc2xOcrService;
import com.xxx.medigw.rpc.model.ParserProgress;
import com.xxx.medigw.rpc.model.ParserResult;
import com.xxx.medigw.rpc.FileParserService;
import com.xxx.sofa.runtime.api.annotation.SofaService;
import com.xxx.sofa.runtime.api.annotation.SofaServiceBinding;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 文件解析服务实现类
*
* 主要职责:
* 1. 提供文件解析进度查询功能
* 2. 提供文件解析结果获取功能
*
* 本服务作为业务层与底层OCR服务的中间层,提供统一的错误处理和日志记录
*/
@Service
public class FileParserServiceImpl implements FileParserService {
/**
* 日志记录器
*/
private static final Logger LOGGER = LoggerFactory.getLogger(FileParserServiceImpl.class);
/**
* 文档解析服务
*/
@Autowired
private Doc2xOcrService doc2xOcrService;
/**
* 查询文件解析进度
*
* 根据任务ID查询文件解析的当前进度状态,提供统一的错误处理
*
* @param taskId 解析任务ID
* @return 解析进度信息,包含状态、总进度百分比、详细进度映射和错误信息(如果有)
* @throws IllegalArgumentException 当参数无效时抛出
* @throws RuntimeException 当系统发生异常时抛出
*/
@Override
public ParserProgress queryParserProgress(String taskId) {
// 1. 参数校验
if (taskId == null || taskId.isEmpty()) {
LoggerUtil.warn(LOGGER, "查询解析进度失败,任务ID不能为空");
throw new IllegalArgumentException("任务ID不能为空");
}
try {
// 2. 调用服务查询解析进度
ParserProgress progress = doc2xOcrService.queryParserProgress(taskId);
// 3. 处理查询结果
if (progress == null) {
LoggerUtil.error(LOGGER, "获取解析进度失败,taskId: {0}", taskId);
throw new RuntimeException("获取解析进度失败,请稍后重试");
}
LoggerUtil.info(LOGGER, "查询解析进度成功,taskId={0}, status={1}, progress={2}%",
taskId, progress.getStatus(), progress.getTotalProgress());
// 4. 返回进度信息
return progress;
} catch (Exception e) {
// 5. 异常处理
LoggerUtil.error(e, LOGGER, "查询解析进度异常,taskId: {0}", taskId);
throw new RuntimeException("查询解析进度异常: " + e.getMessage(), e);
}
}
/**
* 获取文件解析结果
*
* 根据任务ID获取文件解析完成后的结果数据,提供统一的错误处理
*
* @param taskId 解析任务ID
* @return 解析结果数据,结构化的ParserResult对象
* @throws IllegalArgumentException 当参数无效时抛出
* @throws RuntimeException 当系统发生异常时抛出
*/
@Override
public ParserResult fetchResult(String taskId) {
// 1. 参数校验
if (taskId == null || taskId.isEmpty()) {
LoggerUtil.warn(LOGGER, "获取解析结果失败,任务ID不能为空");
throw new IllegalArgumentException("任务ID不能为空");
}
try {
// 2. 调用服务获取解析结果
ParserResult result = doc2xOcrService.fetchResult(taskId);
// 3. 处理获取结果
if (result == null) {
LoggerUtil.error(LOGGER, "获取解析结果失败,taskId: {0}", taskId);
throw new RuntimeException("获取解析结果失败,可能解析尚未完成或解析失败");
}
LoggerUtil.info(LOGGER, "获取解析结果成功,taskId={0}, content长度={1}",
taskId, result.getContent() != null ? result.getContent().length() : 0);
// 4. 返回结果数据
return result;
} catch (Exception e) {
// 5. 异常处理
LoggerUtil.error(e, LOGGER, "获取解析结果异常,taskId: {0}", taskId);
throw new RuntimeException("获取解析结果异常: " + e.getMessage(), e);
}
}
}
===
</content>
</change>
</file>
将这段 prompt 和响应贴到 Apply 的界面中,就能进行代码合并。
点击 Merge Changes,就可以自动合并代码。
接下来我解释一下这段神奇的 XML Diff 到底做了什么。这段 prompt 提供了使用 XML 格式指令来控制代码编辑助手进行代码修改的详细指南。它定义了助手的角色、能力、可用的工具和操作,以及进行各种代码编辑操作(如创建、修改、重写和删除文件)时必须遵循的具体 XML 格式。
1.Role (角色)
输出格式: 强调助手在回复时需要提供完整的指令或代码行,并使用 XML 格式。
2.Capabilities (能力)
避免占位符: 强调输出的代码必须完整,不能使用 ... 或 // existing code here 这样的占位符。
3.Tools & Actions (工具 & 操作)
这部分列出了助手可用的具体工具,以及每个工具对应的操作:
delete: 删除文件。<content> 标签为空。
4.Format to Follow for Repo Prompt's Diff Protocol (仓库 Prompt 的 Diff 协议格式)
这是核心部分,详细说明了 XML 格式的结构:
<file path="path/to/example.swift" action="one_of_the_tools">
<change>
<search>: (仅用于 modify 操作)
对于 delete 操作,<content> 为空。
<!-- Add more <change> blocks ... -->: 提示可以在同一个 <file> 标签内添加多个 <change> 标签来执行多个编辑操作。
5.Tools Demonstration (工具演示)
提供了一些简单的 XML 示例,演示如何使用不同的工具:
<file path="RewriteMe.swift" action="rewrite">: 再次强调 rewrite 操作不需要 <search> 标签。
6.Format Guidelines (格式指南)
总结了 XML 格式的关键规则:
delete: 用于删除文件,<content> 为空。
7.Code Examples(代码示例)
提供了一系列代码示例,包括:
Delete a File: 演示 delete 操作,删除一个文件。
8.Final Notes (最终注意事项)
总结了关键要点和注意事项:
总结
AI 编码提升的效率跟程序员的水平是正相关的,现在的 AI 有点像一个实习生,你必须要把想让它做的事情写的很清楚,如果你自己都不知道要如何实现一个功能,AI 就更不知道了。当然开发过程里面还有一大部分的时间是在调试上面,这块要让 AI 帮忙也需要提问者有足够的知识储备。所以我们也不能期望过高,这样你才能有耐心好好带带这位实习生。
随着 AI 能力的进步,很多人觉得 AI 很快就能取代我们这些程序员,他们觉得产品经理可以直接绕过程序员,让 AI 直接开发出他们想要的产品。作为一名有 8 年工作经验,在手工编程古典时代成长起来的程序员,我其实并不担心这个。
写代码本身其实不算是很大的工作量,一旦你熟悉了语法和中间件,写代码确实跟搬砖区别不大。我们大部分时间主要是花在解决环境问题,跟上下游联调,跟产品一起澄清需求这些事情上面。即使 AI 能够帮我们完成全链路的联调,也取代不了程序员。
首先,知道应该修改哪一行代码,比知道如何修改这行代码值钱一千倍。其次,开发软件真正的难点不在于写代码,而是在于定义清楚问题。最后,当 AGI 真的出现的时候,我觉得应该讨论的不是程序员会不会被取代的问题,而是应该担心碳基生物如何跟硅基生物共存了。
云原生企业级数据湖
基于对象存储 OSS 构建的数据湖,可对接多种数据输入方式,存储任何规模的结构化、半结构化、非结构化数据,打破数据湖孤岛。
点击阅读原文查看详情。