Dart 工程化 03:包发布与私有仓库
C 世界里共享代码靠
.a/.so加头文件,Dart 世界里共享代码靠 package。把一个库发到 pub.dev,意味着全球开发者一条dart pub add就能用;把包发到私有仓库,则是团队内部复用代码的正规军。本章覆盖从「单包发布」到「多包仓库」再到「私有 pub 服务」的完整链路。前置知识:11 包管理与模块(pubspec、依赖语法)、01 项目架构与规范(版本与目录约定)。
一、package 与 application
pubspec.yaml 里的 publish_to: none 不是装饰,它决定这个工程是「被依赖的库」还是「最终交付的应用」。
| 维度 | package(库) | application(应用) |
|---|---|---|
| 入口 | lib/<name>.dart 导出公共 API | bin/、lib/main.dart 或 Flutter 入口 |
| 能否被依赖 | 可以,pub 生态一等公民 | 一般不被依赖(publish_to: none) |
| 版本管理 | 必须遵循语义化版本 | 版本号只用于自身发布 |
| API 稳定性 | 公共 API 是合同,破坏性变更要升 MAJOR | 内部随意重构 |
| 测试重点 | 公共 API 行为、向后兼容 | 端到端功能 |
| 典型结构 | lib/ + test/ + example/ | 完整应用目录 + 平台工程 |
# 应用:禁止发布
name: my_app
publish_to: none# 库:准备发布
name: my_utils
description: 一组零依赖的 Dart 工具函数,适用于 CLI 与服务端。
version: 1.0.0
repository: https://github.com/your-org/my_utils
environment:
sdk: ^3.4.0判断标准:这个工程会被别的工程 import 吗?会,就当 package 设计;不会,就是 application,不必为 API 兼容性背包袱。
二、发布到 pub.dev 完整流程
2.1 发布前检查清单
| 项 | 要求 |
|---|---|
name | 全小写加下划线,pub.dev 上全局唯一 |
description | 60-180 字符,说明用途而非罗列功能 |
version | 合法语义化版本,且未在 pub.dev 发布过 |
LICENSE | 必须有,常见 MIT/BSD-3/Apache-2.0 |
CHANGELOG.md | 按版本倒序记录变更,格式见 2.3 |
README.md | 安装、最小示例、API 概览 |
example/ | 可运行示例,pub.dev 会渲染其代码 |
test/ | 公共 API 的测试覆盖 |
| 文档注释 | 所有公开 API 有 /// 注释 |
2.2 发布流程
dart pub publish --dry-run # 只检查不发布:列出将上传的文件与全部警告
dart pub publish # 真正发布,会要求二次确认--dry-run 的输出必须逐条处理:警告(warning)会阻止发布,提示(hint)虽不阻止但应尽量消除。常见警告:缺少 LICENSE、README 过长代码块、未提交的生成文件、依赖版本范围过宽。
# 用 pana 在本地做与 pub.dev 相同的评分检查
dart pub global activate pana
dart pub global run pana . # 输出分数与问题列表,目标 130/1302.3 CHANGELOG 与版本
<!-- CHANGELOG.md -->
## 1.1.0
- 新增 `retry` 工具函数,支持指数退避。
- `HttpClient` 增加超时参数(向后兼容)。
## 1.0.1
- 修复 `parseDuration` 对 "1h30m" 的解析错误。
## 1.0.0
- 首个稳定版本。2.4 发布验证
dart pub publish --dry-run # 本地验证
dart pub add my_utils --dry-run # 在另一个工程验证可解析
# 发布后在干净容器里验证:
docker run --rm dart:stable dart pub global activate my_utils发布后的包不可删除(只能标记 discontinued),因此 dry-run 与版本号检查必须成为肌肉记忆。
三、语义化版本与破坏性变更
MAJOR.MINOR.PATCH 在 Dart 生态里有额外含义:Dart 没有命名空间,用户升级 minor 也可能拿到新 API,因此 API 设计要更保守。
| 变更类型 | 版本位 | 示例 |
|---|---|---|
| 删除/重命名公开 API、改签名、改行为 | MAJOR | fetch(url) 改为 fetch(Uri) |
| 新增公开 API、新增可选参数、放宽约束 | MINOR | 新增 fetchWithRetry |
| 修 bug、改文档、性能优化 | PATCH | 修复空列表崩溃 |
| 仅改内部实现 | PATCH | 重构私有类 |
破坏性变更的处理策略:
- 废弃而非删除:先标
@Deprecated('Use fetchWithRetry instead. Will be removed in 3.0.0'),至少保留一个 MAJOR 周期; - 迁移指南:在 CHANGELOG 与 README 写清「旧写法 → 新写法」;
- 依赖约束要诚实:
dependencies: foo: ^2.0.0表示允许 2.x 任意版本,若你的包依赖了 2.3 才有的 API,写>=2.3.0 <3.0.0。
@Deprecated('将在 3.0.0 移除,改用 fetchWithRetry')
Future<String> fetch(String url) => fetchWithRetry(Uri.parse(url));四、多包仓库管理:melos
4.1 什么时候需要 monorepo
一个 SDK 拆成 core、network、ui 三个包,它们版本互相依赖、需要一起测试——用三个 Git 仓库管理会陷入「改 A 要发版才能让 B 用」的循环。melos 是 Dart 生态的 monorepo 工具,解决四件事:本地互相依赖、统一版本、批量执行命令、按依赖顺序发布。
4.2 工程结构
my_sdk/
melos.yaml
pubspec.yaml # workspace 根(Dart 3.6+ 可直接用 pub workspaces)
packages/
core/ # 基础类型与工具
pubspec.yaml
network/ # 依赖 core
pubspec.yaml
ui/ # 依赖 core + network
pubspec.yaml
scripts/
analyze.sh# melos.yaml
name: my_sdk
packages:
- packages/*
command:
bootstrap:
runPubGetInParallel: true
version:
# 版本号统一写入各包 pubspec,并在 CHANGELOG 顶部插入条目
linkToCommits: true4.3 melos 工作流
flowchart LR A["melos bootstrap<br/>本地互相 link"] --> B["开发与测试<br/>melos run test"] B --> C["melos version<br/>统一改版本 + CHANGELOG"] C --> D["melos publish<br/>按依赖拓扑顺序发布"] D --> E["git tag + push<br/>记录发布点"]
dart pub global activate melos
melos bootstrap # 解析全部包并建立本地依赖链接
melos run analyze # 在 melos.yaml 定义的自定义脚本
melos version --yes # 按变更自动决定版本位并写入各包
melos publish --no-dry-run # 按依赖顺序发布到 pub.devmelos version 会自动处理内部依赖:ui 依赖 network 且 network 升了 MINOR,melos 会把 ui 的依赖约束一并更新。没有这一步,手动维护十个包的依赖约束是灾难。
五、私有 pub 服务与私有依赖
5.1 私有方案对比
| 方案 | 部署 | 认证 | 适用 |
|---|---|---|---|
| git 依赖 | 无需服务,直接指向仓库 | Git 权限(SSH/Token) | 少量私有包、临时方案 |
| path 依赖 | 本地路径 | 文件系统 | monorepo 内部、本地联调 |
| unpub | 自建 Docker,可托管静态站点 | 可配置,较弱 | 内网快速搭建 |
| 自建 pub_server | dart pub global activate pub_server | 基础,需自行加固 | 学习协议、小团队 |
| 第三方托管(Cloudsmith/Artifactory) | SaaS 或私有部署 | Token、SSO、细粒度权限 | 企业级、有审计需求 |
5.2 git 与 path 依赖写法
dependencies:
# git 依赖:可锁 tag 或 commit,保证可复现
company_logger:
git:
url: https://github.com/your-org/company_logger.git
ref: v1.2.0
# 本地 path 依赖:monorepo 开发时使用
company_core:
path: ../company_core# 私有 git 仓库的认证:用 SSH 或带 token 的 HTTPS
git config --global url."git@github.com:".insteadOf "https://github.com/"规则:生产依赖永远锁 tag 或 commit,绝不依赖分支;分支会移动,构建不可复现。
5.3 unpub 部署示例
# docker-compose.yml
services:
unpub:
image: ghcr.io/eryajf/unpub:latest
ports:
- "4000:4000"
volumes:
- ./data:/data # 上传的包与索引持久化# 使用方配置私有源(放在项目根 .dart_tool 之外的全局配置)
# ~/.pub-cache/credentials.json 或环境变量方式:
# PUB_HOSTED_URL 会替换整个 pub.dev,通常不推荐# 只对单个包指定私有源(推荐)
dart pub add company_logger --hosted-url http://pub.internal.example.com注意:PUB_HOSTED_URL 是全局替换,会让公共包也走私有源;正确的做法是在 pubspec.yaml 的 hosted 字段按包指定:
dependencies:
company_logger:
hosted: http://pub.internal.example.com
version: ^1.2.0六、内部共享包的设计原则
- 稳定 API 优先:内部包被多个业务依赖,破坏性变更的成本比开源包更高(无法要求所有团队同时升级);
- 小而专:一个包只做一件事,
company_utils这种万能包最终会变成依赖地狱; - 零业务语义:公共包不 import 任何业务模块,否则依赖图变成蜘蛛网;
- 接口与实现分离:对外暴露抽象接口与工厂函数,实现类不导出,留出替换空间;
- 可测试性内建:构造函数注入依赖,不在包内读全局配置或单例;
- 文档与示例随包发布:
example/是内部包最好的使用说明书; - 版本策略统一:所有内部包用同一套版本节奏(如统一 MAJOR),减少组合爆炸。
// company_logger/lib/company_logger.dart —— 导出面收敛
export 'src/logger.dart'; // 只导出公共 API
// src/console_logger.dart 不导出,实现细节可随时替换// src/logger.dart
abstract class Logger {
void info(String message);
void error(String message, [Object? error]);
factory Logger.console({String prefix = ''}) => ConsoleLogger(prefix);
}七、文档生成:dart doc
dart doc # 输出到 doc/api/,用浏览器打开 index.html
dart doc --output web/docs # 指定输出目录/// 带指数退避的重试工具。
///
/// 示例:
/// ```dart
/// final result = await retry(() => client.get(url), maxAttempts: 3);
/// ```
///
/// 当 [maxAttempts] 为 0 时立即抛出 [ArgumentError]。
Future<T> retry<T>(Future<T> Function() action, {int maxAttempts = 3}) async {
// 实现
}纪律:文档注释写在公共 API 的声明处;示例代码用 ```dart 包裹以便被分析器检查;dart doc 的警告(未文档化的公开成员)应清零;CI 中可用 dart doc --validate-links 检查链接有效性。
八、示例工程组织
一个可发布包的标准结构:
my_utils/
lib/
my_utils.dart # 唯一公共入口,export 各模块
src/ # 实现细节,外部不应 import
string_utils.dart
duration_utils.dart
test/
string_utils_test.dart
duration_utils_test.dart
example/
main.dart # 可运行示例,pub.dev 会展示
doc/ # dart doc 输出(gitignore)
CHANGELOG.md
LICENSE
README.md
analysis_options.yaml
pubspec.yaml关键约定:外部只能 import package:my_utils/my_utils.dart,src/ 下的路径视为私有;用 implementation_imports lint 规则强制这一点(禁止跨包 import 别人的 src/)。
常见坑
- 未提交生成文件就发布:
dart pub publish --dry-run会警告.g.dart/.freezed.dart不在版本控制中,发布后用户生成失败;确认生成文件入库或用户可自行生成。 - 版本冲突:内部包 A 依赖
core ^1.0.0,包 B 依赖core ^2.0.0,同时引入直接解析失败;monorepo 用 melos 统一升级,多仓库则约定同一 MAJOR。 pubspec.lock入库策略错误:应用(application)必须提交 lock 文件保证可复现;库(package)不提交,让用户自行解析(否则依赖约束被锁死)。- 私有源配置全局覆盖:
PUB_HOSTED_URL让公共包也走内网,应改用hosted字段按包指定。 - 发布后才发现 API 设计错误:pub.dev 不能删除版本;发布前用
example/与下游工程真实试用一轮。 - 废弃 API 直接删除:下游编译失败;先
@Deprecated保留一个大版本。 - README 里粘贴不可运行的伪代码:pub.dev 评分会检查代码块可分析性,示例要能通过
dart analyze。
本章小结
- package 与 application 的分界线是「是否被别的工程依赖」,后者用
publish_to: none明确表态; - 发布 pub.dev 五件套:pubspec 元数据、LICENSE、CHANGELOG、README、example,
--dry-run与 pana 是发布前门禁; - 语义化版本是 API 合同,破坏性变更先废弃一个大版本再删除;
- melos 解决多包仓库的本地链接、统一版本与拓扑发布,
melos version自动处理内部依赖约束; - 私有依赖三种主流方式:git(锁 tag)、path(本地)、私有 hosted(按包指定,勿全局覆盖);
- 内部共享包的设计目标是最小公共面与稳定 API,
src/永远不对外; dart doc把文档注释变成站点,公开 API 文档化应作为发布检查项。
练习
- 发布一个真实小包:创建一个
dart create -t package工程,实现 3-5 个纯函数工具(如字符串截断、时长格式化、重试封装),补齐 LICENSE、CHANGELOG、README 与 example,跑通dart pub publish --dry-run并让 pana 分数达到 130/130。- 验收:dry-run 无 warning;
dart pub global run pana .输出满分;example 可通过dart run执行。
- 验收:dry-run 无 warning;
- 废弃与兼容:给练习 1 的某个函数改名,用
@Deprecated保留旧名一个版本,同时更新 CHANGELOG 的 MINOR 版本条目。- 验收:旧名调用产生 deprecation 警告但可编译;CHANGELOG 中记录迁移写法。
- melos 多包:创建含
core与app_kit两个包的 melos 仓库,app_kit依赖core,跑通 bootstrap 与统一 version。- 验收:
melos bootstrap后app_kit的pubspec.lock指向本地core;melos version能同时更新两包版本并在 CHANGELOG 插入条目。
- 验收:
- 私有依赖:在本地用 path 依赖与 git 依赖各接入一个私有包到示例应用,并说明生产环境该选哪种。
- 验收:应用能
dart pub get并调用两个包的 API;提交一份 200 字以内的选型说明(path 用于 monorepo 联调,git 锁 tag 用于跨仓库交付)。
- 验收:应用能
- 返回目录:Dart 教程目录