代码生成(Eject 逃生舱)

默认场景用运行时按 Schema 直接渲染即可、无需生成代码;当你想把实体/服务/页面当普通手写代码接管演进时,用 known build 把一份 JSON Schema 出成与手写完全同构的可增删改查代码。

最后更新

大多数标准 CRUD 你不需要这一页。Known 的默认做法是让运行时按 Schema 直接把页面渲染出来、不生成任何代码——见 运行时 Schema 渲染。本页讲的是那条"逃生通道":当你想把实体、服务、页面当成普通手写代码拿到自己手里长期演进时,用 known build 把同一份 Schema 出成一套可自由增删改的代码。

Known 的代码生成器把「建模」压缩成一份 JSON:你只描述有哪些实体、每个实体有哪些字段,known build 就把它渲染成一套能编译、能运行、能直接增删改查的 Known 代码——实体自动建表、服务自动注册进 DI、页面自动挂进侧边菜单,全程零手工接线。产出的每个文件都对齐框架户式(服务接口 I{Name}Service、方法名按实体取、FillModel → Validate → TransactionAsync 的保存套路、BasePage + ConsolePage + Table + Popconfirm + Modal 的页面骨架),你可以像读手写代码一样读它、改它。

先看整段流程跑起来是什么样(约 50 秒,从建模到登录后台看到数据落库):

三步跑通

前置是 .NET 10 SDK 与本仓库源码。命令行有两种调用方式,任选其一:

# 方式 A:直接用源码跑(无需安装)
dotnet run --project Known.Cli -c Release -- <命令> <参数...>

# 方式 B:装成本地 dotnet tool,命令名 known
dotnet pack Known.Cli -c Release -o ./nugpkg
dotnet tool install --add-source ./nugpkg --global Known.Cli

下面统一写作 known ...。

1. 生成起步脚手架

known new OrderDemo

产出 OrderDemo/ 目录与 OrderDemo.slnx,内含四个工程:业务插件 OrderDemo/(页面、服务、数据都写在这里,也是本次出码落点)、纯前端宿主 OrderDemoWasm/、全栈一体宿主 OrderDemoWeb/(推荐先用)、桌面宿主 OrderDemoWin/。默认 SQLite,换库用 --db sqlserver|mysql|postgres|oracle|dm|access。

2. 按 Schema 出码

先写一份元数据 samples/order.schema.json:

{
  "name": "OrderDemo",
  "entities": [
    {
      "name": "Order",
      "displayName": "订单",
      "table": "biz_Order",
      "fields": [
        { "name": "OrderNo",  "label": "单号", "type": "string", "required": true, "maxLength": 50 },
        { "name": "Customer", "label": "客户", "type": "string", "maxLength": 50 },
        { "name": "Quantity", "label": "数量", "type": "int",    "required": true },
        { "name": "Amount",   "label": "金额", "type": "decimal","required": true },
        { "name": "Status",   "label": "状态", "type": "string", "maxLength": 20 }
      ]
    },
    {
      "name": "Customer",
      "displayName": "客户",
      "table": "biz_Customer",
      "fields": [
        { "name": "Name",  "label": "姓名", "type": "string", "required": true, "maxLength": 50 },
        { "name": "Phone", "label": "电话", "type": "string", "maxLength": 30 },
        { "name": "Vip",   "label": "会员", "type": "bool" }
      ]
    }
  ]
}

把它生成进业务插件工程:

known build --schema samples/order.schema.json --project OrderDemo/OrderDemo --force

known build 为每个实体产出「四件套」,并首次创建可手写的扩展点:

文件 状态 作用
Entities/Order.cs Generated [Table] POCO,继承 EntityBase(partial)
Services/IOrderService.cs Generated [WebApi] 契约,前后端同构消费;方法名按实体取
Services/OrderService.cs Generated [Service] 实现,直连 Database,走标准事务套路
Pages/OrderList.razor Generated [Menu] 挂载、@inherits BasePage 的分页 + 增删改页
Entities/OrderCustom.cs Seeded 用户扩展分部,首次创建后不再被覆盖
Services/OrderServiceCustom.cs Seeded 服务扩展分部,首次创建后不再被覆盖

3. 构建并运行

cd OrderDemo
dotnet build OrderDemo.slnx -c Release      # 0 警告 0 错误
dotnet run --project OrderDemoWeb           # 启动即自动建表并挂载菜单

浏览器打开后,用演示账号 admin / admin123 登录,侧边菜单会自动出现 订单 与 客户 两项。

出码产出的代码长什么样

关键在于:生成的代码就是你会手写的那套,没有私有基类、没有魔法运行时。对照看更放心。

实体(一张表)

namespace OrderDemo.Entities;

/// 订单。
[Table("biz_Order")]
[DisplayName("订单")]
public partial class Order : EntityBase
{
    [Required] [MaxLength(50)] [DisplayName("单号")]
    public string OrderNo { get; set; }

    [MaxLength(50)] [DisplayName("客户")]
    public string Customer { get; set; }

    [Required] [DisplayName("数量")]
    public int Quantity { get; set; }

    [Required] [DisplayName("金额")]
    public decimal Amount { get; set; }

    [MaxLength(20)] [DisplayName("状态")]
    public string Status { get; set; }
}

主键与审计列(Id / CreateBy / CreateTime / Version …)由 EntityBase 提供,无需自己写。[Table("biz_Order")] 决定表名——启动时 EnsureSchemaAsync 按实体自动建表、给缺的列补上(非破坏式,只 CREATE TABLE / ADD COLUMN,不删改已有列)。

服务契约([WebApi])

[WebApi]
public interface IOrderService
{
    Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria);
    Task<Order> GetOrderAsync(string id);
    Task<Result> SaveOrderAsync(Order entity);
    Task<Result> DeleteOrdersAsync(List<string> ids);
}

接口标 [WebApi],就同时可被前端 ServiceProxy 与后端 WebApiInvoker 同构消费。方法名按实体取(QueryOrdersAsync / SaveOrderAsync …),写操作统一返回非泛型 Result(看 IsValid / Message)。

服务实现([Service],标准事务套路)

[Service]
public partial class OrderService(Database db) : IOrderService
{
    public Task<PagingResult<Order>> QueryOrdersAsync(PagingCriteria criteria)
        => db.QueryPageAsync<Order>(criteria ?? new PagingCriteria { OrderBys = ["CreateTime desc"] });

    public Task<Order> GetOrderAsync(string id)
        => db.QueryByIdAsync<Order>(id);

    public async Task<Result> SaveOrderAsync(Order entity)
    {
        if (entity == null)
            return Result.Error("订单不能为空!");

        // Id 为空 → 新增;否则先取回库里带跟踪的原实体,再拷入业务字段,保留 Original 供按变更列 UPDATE。
        var model = string.IsNullOrWhiteSpace(entity.Id)
            ? null
            : await db.QueryByIdAsync<Order>(entity.Id);
        model ??= new Order();
        model.FillModel(entity);

        var vr = model.Validate();
        if (!vr.IsValid)
            return vr;

        return await db.TransactionAsync("保存", async tx =>
        {
            await tx.SaveAsync(model);
            entity.Id = model.Id;
        }, entity);
    }

    public async Task<Result> DeleteOrdersAsync(List<string> ids)
    {
        if (ids == null || ids.Count == 0)
            return Result.Error("请选择要删除的订单!");

        return await db.TransactionAsync("删除", async tx =>
        {
            foreach (var id in ids)
                await tx.DeleteAsync<Order>(id);
        });
    }
}

保存刻意不直接存前端传来的实体——它没有 Version 与审计快照,直接存会破坏乐观锁和"按变更列 UPDATE"。正确做法是取回带跟踪的原实体、FillModel 拷字段、Validate() 校验,再包进 TransactionAsync。这套正是框架内置服务(如语言管理)一直在用的写法。

页面(BasePage + ConsolePage + Table + Popconfirm + Modal)

@page "/order"
@inherits BasePage
@attribute [Menu("订单", Icon = "list", Order = 10)]
@inject IOrderService Orders

<ConsolePage Title="订单管理" Description="由 Known 代码生成产出的标准 CRUD 页:列表、搜索、新增、编辑、删除。"
             Searchable="true" @bind-SearchText="keyword" OnSearch="SearchAsync" OnSearchClear="ResetAsync"
             SearchPlaceholder="搜索单号…">
    <HeaderActions>
        <Button Kind="primary" Icon="plus" Text="新增订单" OnClick="OpenAdd" />
    </HeaderActions>
    <ChildContent>
        <Table TItem="Order" Columns="columns" Items="items" Loading="loading"
               Striped="true" ActionTitle="操作"
               ShowPagination="true" Total="total" PageSize="pageSize"
               Page="pageIndex" PageChanged="OnPageChangedAsync">
            <RowActions Context="row">
                <Button Kind="soft" Size="xs" Text="编辑" OnClick="() => OpenEdit(row)" />
                <Popconfirm Text="确定删除这条订单吗?" OkKind="danger" OnConfirm="() => DeleteAsync(row)">
                    <Button Kind="outline" Size="xs" Text="删除" />
                </Popconfirm>
            </RowActions>
        </Table>
    </ChildContent>
</ConsolePage>

<Modal Title="@modalTitle" Icon="edit" Visible="editVisible" VisibleChanged="v => editVisible = v">
    <ChildContent>
        @if (editing != null)
        {
            <div class="gen-form">
                <div class="gen-field">
                    <span class="gen-label">单号 *</span>
                    <Input Value="@editing.OrderNo" ValueChanged="v => editing.OrderNo = v" Live="true" Placeholder="请输入单号" />
                </div>
                <div class="gen-field">
                    <span class="gen-label">数量 *</span>
                    <Number Value="@((double)editing.Quantity)" ValueChanged="v => editing.Quantity = (int)v" />
                </div>
                @* …其余字段按类型映射… *@
            </div>
        }
    </ChildContent>
    <Footer>
        <Button Kind="outline" Text="取消" OnClick="() => editVisible = false" />
        <Button Kind="primary" Icon="check" Text="保存" Loading="saving" OnClick="SaveAsync" />
    </Footer>
</Modal>

@code 里是标准的「服务端分页 + 关键字过滤 + 弹窗保存」:LoadAsync 组 PagingCriteria(PageIndex/PageSize/OrderBys,关键字用 Parameters["@OrderNo"] = "%kw%" 走 like),SaveAsync 调 Orders.SaveOrderAsync(editing) 后按 result.IsValid 弹 UI.Success / UI.Error。UI 与 Local 由 BasePage 自带,无需自己注入。

字段类型 → 表单控件映射

Schema type C# 类型 生成控件
string string Input(带 MaxLength)
int / decimal int / decimal Number 步进器(显式 double 转换绑定)
bool bool Switch
datetime DateTime DateInput

字段选项:name(必填,合法 C# 标识符)、label(列头与表单标题)、type(别名宽松匹配)、required、maxLength、hideInList(列表页隐藏该列,仍进表单)。JSON 允许 // 注释与尾逗号;非法标识符会直接报错而非生成坏代码。

为什么"放进去就生效"

脚手架的 AppConfig.cs 里有一句 Config.AddModule(assembly):启动时它扫描本程序集所有带框架特性的类型——[Table] 自动进实体表并建表、[WebApi] / [Service] 自动接好客户端与服务的通路、[Menu] 的页面自动挂上导航。所以你不需要回任何地方登记路由或菜单。同一份插件代码在 OrderDemoWasm(纯前端)、OrderDemoWin(桌面)里跑起来也不用改:页面注入的 IOrderService 会按运行形态自动走 HTTP 代理或本地直调。

Generated 与 Seeded 的边界

Generated 文件带 auto-generated 头,Schema 变更后重跑 known build 会按内容差异覆盖(--force 即便一致也强写)。Seeded(*Custom.cs)是你的手写落点,代码生成永不覆盖——计算属性、派生查询、集成钩子都往这里放。这条边界为「同一份 Schema 反复出码到已有项目」预留了用户代码与生成代码互不踩踏的空间。

需要意识到:一旦你跑这条命令、把产物纳入仓库并开始手改,就等于从"运行时按 Schema 渲染"里 eject 了出来——此后这个实体的页面与服务代码归你维护,改模型不再只是编辑一份 JSON。所以只在确实要接管代码时才用它;标准场景留在运行时渲染更省心。

常见坑

  • 菜单/表没出现:Config.AddModule 是启动时扫描的,加完文件要重新 dotnet run;热重载只更界面、不一定触发重扫特性。
  • --project 定位失败:省略时命令会在当前目录及一层子目录里按 AppConfig.cs 特征找插件工程;命中多个就用 --project 指定其一。
  • 列一直空:TableColumn.Field 按属性名反射取值,大小写要和实体属性一致(生成代码已对齐,手改时留意)。
  • init / pull / merge / apply 仍是占位:目前只有 new 与 build 落地,联网同步类命令尚未实现。

相关

微信公众号二维码 扫码关注公众号
QQ 交流群二维码 扫码加入 QQ 群