Compare commits

..

1 Commits

Author SHA1 Message Date
61c37e0dba test: verify remote business module upgrade flow 2026-09-21 00:23:22 +08:00
9 changed files with 168 additions and 15 deletions

View File

@@ -1,5 +1,10 @@
# Changelog
## v0.3.1
- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。
## v0.3.0
- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。
@@ -10,6 +15,7 @@
- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。
- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。
- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。
- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。
## v0.2.0

View File

@@ -5,13 +5,13 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.3.0
go get git.yuntuo.hk/k2safe/go-omni@v0.3.1
```
创建新业务项目:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.1
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
@@ -88,7 +88,7 @@ application := omni.NewWithRouter(myRouter)
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.3.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.3.1 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
`app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。
@@ -166,6 +166,14 @@ userService := service.NewUsers(services)
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
从远程版本生成并升级一个独立业务模块,可运行:
```sh
python3 scripts/verifyBusiness.py --version v0.3.1
```
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.3.1`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
在框架根目录运行:
```sh

View File

@@ -28,9 +28,11 @@
- 完整用户 CRUD 已用相同业务注册代码在两种适配器上验证;全量 race 测试通过。
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.3.0`,从源码目录外消费远程版本与公共契约测试包。
## 4. 实际业务试用(待完成)
## 4. 实际业务试用(已完成,v0.3.1)
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.3.1`,全程无本地 replace。
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.3.1`。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 与适配层契约验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。下一阶段先在真实业务模块中使用固定远程版本,再根据接入反馈收敛 API。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,生产压测与自研网络内核尚未完成。后续进入稳定性与性能验证,再决定稳定版范围。

View File

@@ -3,7 +3,7 @@
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.1
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
@@ -16,7 +16,7 @@ go run ./cmd/server
需要选择其他已发布版本时,使用完整版本号:
```sh
go-omni new --framework-version v0.3.0 --module example.com/billing ./billing
go-omni new --framework-version v0.3.1 --module example.com/billing ./billing
```
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
@@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
```sh
python3 scripts/verifyScaffold.py --version v0.3.0
python3 scripts/verifyDatabase.py --version v0.3.0
python3 scripts/verifyScaffold.py --version v0.3.1
python3 scripts/verifyDatabase.py --version v0.3.1
```
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.3.0"
const defaultFrameworkVersion = "v0.3.1"
var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)

137
scripts/verifyBusiness.py Executable file
View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Verify a real generated business module across a released framework upgrade."""
import argparse
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
from verifyScaffold import MODULE, run
def free_address():
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
return f"127.0.0.1:{listener.getsockname()[1]}"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.3.1", help="Target framework release")
parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade")
args = parser.parse_args()
env = dict(os.environ, GOWORK="off")
env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"]))
with tempfile.TemporaryDirectory(prefix="go omni business ") as temporary:
root = Path(temporary).resolve()
env["GOBIN"] = str(root / "bin")
print(f"Installing released CLI {args.version}", flush=True)
run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], root, env)
cli = root / "bin" / "go-omni"
project = root / "billing service"
run([
str(cli), "new", "--database", "memory", "--framework-version",
args.from_version, "--module", "example.com/billing", str(project),
], root, env)
run([args.go, "mod", "tidy"], project, env)
manifest = (project / "go.mod").read_text()
if f"{MODULE} {args.from_version}" not in manifest or "replace " in manifest:
raise AssertionError("initial business project was not pinned to the expected remote version")
run([args.go, "test", "-race", "./..."], project, env)
run([args.go, "get", f"{MODULE}@{args.version}"], project, env)
run([args.go, "mod", "tidy"], project, env)
upgraded = (project / "go.mod").read_text()
if f"{MODULE} {args.version}" not in upgraded or "replace " in upgraded:
raise AssertionError("business project upgrade did not produce a clean remote pin")
run([args.go, "test", "-race", "./..."], project, env)
binary = project / "billing-server"
run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env)
address = free_address()
log_file = project / "business.log"
server_env = dict(env)
server_env.update(
GO_OMNI_HTTP_ADDR=address,
GO_OMNI_MODE="release",
GO_OMNI_ENABLE_LOGGER="true",
GO_OMNI_LOG_FILE=str(log_file),
GO_OMNI_LOG_FORMAT="json",
GO_OMNI_SHUTDOWN_TIMEOUT="2s",
)
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(method, path, body=None, request_id="business-request"):
payload = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
f"http://{address}{path}", data=payload, method=method,
headers={"Content-Type": "application/json", "X-Request-ID": request_id},
)
try:
with client.open(req, timeout=3) as response:
return response.status, response.headers, json.load(response)
except urllib.error.HTTPError as error:
return error.code, error.headers, json.loads(error.read())
process = subprocess.Popen(
[str(binary)], cwd=project, env=server_env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
try:
deadline = time.monotonic() + 15
while True:
if process.poll() is not None:
output = process.stdout.read() if process.stdout else ""
raise RuntimeError(f"business service exited during startup: {output}")
try:
status, _, _ = request("GET", "/health/live")
if status == 200:
break
except OSError:
pass
if time.monotonic() >= deadline:
raise RuntimeError("business service did not start")
time.sleep(0.05)
status, headers, body = request("GET", "/health/ready")
if status != 200 or body.get("code") != "READY":
raise AssertionError(f"readiness failed: {status} {body}")
status, headers, body = request("POST", "/users", {"name": "broken"}, "invalid-request")
if status != 400 or body.get("code") != "INVALID_BODY" or headers.get("X-Request-ID") != "invalid-request":
raise AssertionError(f"validation contract failed: {status} {body}")
status, _, body = request("POST", "/users", {"name": "Alice", "email": "alice@example.com"}, "create-request")
if status != 201 or body.get("data", {}).get("email") != "alice@example.com":
raise AssertionError(f"business create failed: {status} {body}")
status, _, body = request("POST", "/users", {"name": "Duplicate", "email": "ALICE@example.com"}, "duplicate-request")
if status != 409 or body.get("code") != "EMAIL_TAKEN":
raise AssertionError(f"business error mapping failed: {status} {body}")
process.send_signal(signal.SIGTERM)
if process.wait(timeout=8) != 0:
raise AssertionError("business service did not exit cleanly")
finally:
if process.poll() is None:
process.kill()
process.wait(timeout=5)
content = log_file.read_text()
if '"http.request"' not in content or '"requestId":"create-request"' not in content:
raise AssertionError(f"access log did not contain business request correlation: {content}")
if '"service":"example.com/billing"' not in content:
raise AssertionError(f"business service name was not attached to logs: {content}")
if "private" in content.lower() and "password" in content.lower():
raise AssertionError("sensitive text leaked into business logs")
print(f"PASS business module: {args.from_version} -> {args.version}, clean remote pin, CRUD, validation, errors, requestId and logs", flush=True)
if __name__ == "__main__":
main()

View File

@@ -145,7 +145,7 @@ class Server:
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.3.0")
parser.add_argument("--version", default="v0.3.1")
parser.add_argument("--cli", help="Use a locally built CLI")
parser.add_argument("--framework-dir", help="Local framework source for pre-release checks")
parser.add_argument("--image", default="mysql:8.4")

View File

@@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go", help="Go executable")
parser.add_argument("--version", default="v0.3.0", help="Released CLI version to install")
parser.add_argument("--version", default="v0.3.1", help="Released CLI version to install")
parser.add_argument("--cli", help="Use a locally built CLI instead of installing")
parser.add_argument("--framework-version", help="Explicit version passed to the CLI")
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")

View File

@@ -13,7 +13,7 @@ from verifyScaffold import MODULE, run
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--go", default="go")
parser.add_argument("--version", default="v0.3.0")
parser.add_argument("--version", default="v0.3.1")
parser.add_argument("--framework-dir", help="Use a local checkout before release")
args = parser.parse_args()
env = dict(os.environ, GOWORK="off")