Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61c37e0dba |
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.3.1
|
||||||
|
|
||||||
|
- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||||
|
- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。
|
||||||
|
|
||||||
## v0.3.0
|
## v0.3.0
|
||||||
|
|
||||||
- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。
|
- 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。
|
||||||
@@ -10,6 +15,7 @@
|
|||||||
- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。
|
- 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。
|
||||||
- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。
|
- **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。
|
||||||
- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。
|
- `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。
|
||||||
|
- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。
|
||||||
|
|
||||||
## v0.2.0
|
## v0.2.0
|
||||||
|
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -5,13 +5,13 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi
|
|||||||
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.3.0
|
go get git.yuntuo.hk/k2safe/go-omni@v0.3.1
|
||||||
```
|
```
|
||||||
|
|
||||||
创建新业务项目:
|
创建新业务项目:
|
||||||
|
|
||||||
```sh
|
```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
|
go-omni new --module example.com/billing ./billing
|
||||||
cd billing
|
cd billing
|
||||||
go mod tidy
|
go mod tidy
|
||||||
@@ -88,7 +88,7 @@ application := omni.NewWithRouter(myRouter)
|
|||||||
|
|
||||||
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
|
仓库还提供了使用标准库路由的 `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 类型。
|
`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 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
|
[`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
|
```sh
|
||||||
|
|||||||
10
ROADMAP.md
10
ROADMAP.md
@@ -28,9 +28,11 @@
|
|||||||
- 完整用户 CRUD 已用相同业务注册代码在两种适配器上验证;全量 race 测试通过。
|
- 完整用户 CRUD 已用相同业务注册代码在两种适配器上验证;全量 race 测试通过。
|
||||||
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.3.0`,从源码目录外消费远程版本与公共契约测试包。
|
- 自动验收入口:`python3 scripts/verifyTransports.py --version v0.3.0`,从源码目录外消费远程版本与公共契约测试包。
|
||||||
|
|
||||||
## 4. 实际业务试用(待完成)
|
## 4. 实际业务试用(已完成,v0.3.1)
|
||||||
|
|
||||||
- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。
|
- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.3.1`,全程无本地 replace。
|
||||||
- 根据真实接入问题收敛公共 API,再评估稳定版范围。
|
- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。
|
||||||
|
- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。
|
||||||
|
- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.3.1`。
|
||||||
|
|
||||||
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 与适配层契约验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。下一阶段先在真实业务模块中使用固定远程版本,再根据接入反馈收敛 API。
|
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,生产压测与自研网络内核尚未完成。后续进入稳定性与性能验证,再决定稳定版范围。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||||
|
|
||||||
```sh
|
```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
|
go-omni new --module example.com/billing ./billing
|
||||||
cd billing
|
cd billing
|
||||||
go mod tidy
|
go mod tidy
|
||||||
@@ -16,7 +16,7 @@ go run ./cmd/server
|
|||||||
需要选择其他已发布版本时,使用完整版本号:
|
需要选择其他已发布版本时,使用完整版本号:
|
||||||
|
|
||||||
```sh
|
```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` 等浮动引用不接受为固定版本。
|
`--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。
|
||||||
@@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库
|
|||||||
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 scripts/verifyScaffold.py --version v0.3.0
|
python3 scripts/verifyScaffold.py --version v0.3.1
|
||||||
python3 scripts/verifyDatabase.py --version v0.3.0
|
python3 scripts/verifyDatabase.py --version v0.3.1
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// 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-]+)*)?$`)
|
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
137
scripts/verifyBusiness.py
Executable 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()
|
||||||
@@ -145,7 +145,7 @@ class Server:
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--go", default="go")
|
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("--cli", help="Use a locally built CLI")
|
||||||
parser.add_argument("--framework-dir", help="Local framework source for pre-release checks")
|
parser.add_argument("--framework-dir", help="Local framework source for pre-release checks")
|
||||||
parser.add_argument("--image", default="mysql:8.4")
|
parser.add_argument("--image", default="mysql:8.4")
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None):
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--go", default="go", help="Go executable")
|
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("--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-version", help="Explicit version passed to the CLI")
|
||||||
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")
|
parser.add_argument("--framework-dir", help="Local checkout for pre-release verification")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from verifyScaffold import MODULE, run
|
|||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--go", default="go")
|
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")
|
parser.add_argument("--framework-dir", help="Use a local checkout before release")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
env = dict(os.environ, GOWORK="off")
|
env = dict(os.environ, GOWORK="off")
|
||||||
|
|||||||
Reference in New Issue
Block a user