当前位置:准备 Ruby、cc、SQLite
环境要求
为什么CyberTrain 通过 Spinel 编译为 C,所以需要 C 工具链和 SQLite 头文件;cybertrain 命令是一个 gem,所以需要 Ruby。
- Ruby 3.2+ 和 RubyGems,仅供
cybertrain命令使用;应用本身从不在 CRuby 上运行。 - Spinel
2026.09.12,未在其他版本上测试。你无需手动安装:第一个需要它的命令会把该版本构建到~/.cybertrain中(需要几分钟,仅一次),除非PATH上已有该版本的spinel。 - C 工具链(
cc,或CC指定的编译器)和make,以及git和curl,用于构建 Spinel;Spinel 会把每个程序编译为 C。 - SQLite 3 头文件和库:Debian/Ubuntu 上为
libsqlite3-dev;macOS 上随 Xcode 命令行工具提供。 - OpenSSL 头文件(
libssl-dev)是可选的:没有它们时,Spinel 构建时会不带其openssl包,而 CyberTrain 并不使用这个包。
cybertrain doctor 会检查这些工具和头文件以及 Spinel 的安装情况;缺少必需项时,它还会打印适用于你的平台的安装命令。
CyberTrain 如何安装 Spinel某个命令首次需要 spin 时会发生什么
cybertrain new、db、server、build 和 spin 会先查找 Spinel 2026.09.12:首先是 PATH 上已有的该版本的 spinel 和 spin,其次是 ~/.cybertrain/spinel/2026.09.12/ 下的副本;如果都没有,就从 Spinel 的发布标签构建这份副本(git clone、make deps、make -j、make install),只需一次,耗时几分钟。日志位于 ~/.cybertrain/log/spinel-2026.09.12-build.log;如果缺少构建所需的工具,cybertrain 会在开始前指出缺少哪个,并打印适用于你的平台的安装命令。
cybertrain setup 会按需执行同样的操作(--force 会重新构建 ~/.cybertrain 下的副本),并且在正在使用的是它自己的副本时,打印把它加入 PATH 的那一行,方便你自己运行 spin:
export PATH="$HOME/.cybertrain/bin:$PATH"
~/.cybertrain/bin 中存放指向当前所用版本的 spinel 和 spin 链接。即使它不在 PATH 上,cybertrain spin ARGS... 也能在任意目录下用该版本运行 spin。CYBERTRAIN_HOME 可替代 ~/.cybertrain,CYBERTRAIN_SPINEL_HOME=PREFIX 则改用安装在 PREFIX 中的 Spinel(必须是该版本)。PATH 上其他版本的 spinel 永远不会被使用或改动。
没有可以安装的机器?在线试用会在 GitHub Codespace 中打开这个博客,第 02、03、04 和 07 步都已完成。
当前位置:准备 cybertrain CLI
安装 CLI
为什么cybertrain 命令用来创建应用、生成资源脚手架、执行迁移、启动服务和构建。
第一行从 RubyGems 安装它。其余几行是整个教程的概览;下面的步骤会逐条命令讲解。
gem install cybertrain
cybertrain new blog # 首次运行时安装 Spinel 2026.09.12(需要几分钟)
cd blog
cybertrain g scaffold article title:string body:text
cybertrain db migrate
cybertrain server # http://127.0.0.1:3000
cybertrain build # 生产环境:dist/blog、dist/public/、dist/storage/、dist/tmp/
cybertrain 命令是纯 Ruby 编写的,gem 在 CRuby(3.2 或更新版本)下运行它;框架和 Spinel 都不在这个 gem 里。它的命令如下(cybertrain help 会把它们打印出来):
cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]在 NAME 中创建应用,然后在其中运行spin lock和spin run gen,需要时会先安装 Spinel。cybertrain generate scaffold NAME field:type ...(别名g scaffold)为当前目录中的应用添加一个资源。cybertrain db migrate先生成代码,再执行待处理的迁移,然后再次生成(spin run gen; spin run db -- migrate; spin run gen);cybertrain migration作为别名保留。cybertrain db COMMAND...在spin run gen之后运行其他任意数据库命令:status、rollback [N](之后会再次生成)、schema:dump、create。cybertrain server [PORT]先生成代码,然后启动开发服务器(spin run gen; spin run NAME,或spin run NAME -- PORT;默认端口 3000)。cybertrain build构建嵌入了app/views/的 NAME,并组装dist/(二进制文件、public/、storage/、tmp/)。cybertrain spin ARGS...在任意目录下用锁定版本的 Spinel 运行spin:cybertrain spin test、cybertrain spin run gen -- --check。cybertrain setup [--force]把 Spinel2026.09.12安装到~/.cybertrain中,除非PATH上已有该版本的spinel;--force会重新构建~/.cybertrain下的副本。cybertrain doctor检查 C 工具链、SQLite 头文件和 Spinel 的安装情况。cybertrain version打印版本(cybertrain 0.2.1);cybertrain help打印用法。
db、server 和 build 需在应用目录内运行;NAME 是其 spin.toml 中的 [package] name。
当前位置:你编写 blog/
创建应用
为什么你会得到 Rails 的目录约定,外加三个 bin/ 程序:bin/blog.rb(应用)、bin/gen.rb(生成器)和 bin/db.rb(开发时的迁移)。
cybertrain new blog
cd blog
cybertrain new 会写出应用,其 spin.toml 指向与 CLI 相匹配的版本:
[package]
name = "blog"
version = "0.1.0"
[dependencies]
cybertrain = { git = "https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.1" }
然后它会确保 Spinel 已安装(第一次时,这意味着要把 Spinel 2026.09.12 构建到 ~/.cybertrain 中:需要几分钟,仅一次;参见第 00 步),并运行 spin lock(spin 会把框架拉取到其缓存 ~/.cache/spin/packages/ 中,并在 spin.lock 中锁定提交)和 spin run gen,就像 rails new 会运行 bundle install 一样,所以 cybertrain server 可以立即使用。最后它会打印接下来的步骤。框架的任何部分都不会被复制到应用中;请提交 spin.toml 和 spin.lock。
如果 Spinel 或两个 spin 步骤之一失败,已写出的文件会保留,new 以状态码 1 退出,并打印排除原因后需要运行的命令(对于 Spinel:先运行 cybertrain setup,再在新目录中运行 cybertrain spin lock && cybertrain spin run gen)。--skip-spin 只写出文件;--path DIR 改为依赖本地检出的代码,--git URL [--ref R] 依赖位于 URL 的框架(分支或标签 R),--version V 则在 cybertrain 进入 spin-index 后依赖索引中的版本。
应用名使用小写字母、数字和 _,并以字母开头。生成的目录树:
blog/
spin.toml config/app.rb config/routes.rb
db/schema.rb db/migrate/
app/controllers/application_controller.rb app/models/ app/views/ app/helpers/
public/ bin/blog.rb bin/gen.rb bin/db.rb
gen/ storage/ tmp/ test/
bin/blog.rb 是应用唯一的入口,也就是 spin build blog 编译的程序:./blog 提供服务,./blog migrate 和 ./blog db status 管理数据库。bin/gen.rb 运行生成器(spin run gen),bin/db.rb 在开发期间运行迁移器(cybertrain db migrate 会用到它,因为在第一次迁移生成 gen/models/ 之前,bin/blog.rb 无法编译)。gen/ 中已经有 views.rb,即已提交的、空的内嵌视图表。
ApplicationController 一开始是这样的,因此找不到记录时会通过 rescue_from 映射为 404:
class ApplicationController < Cybertrain::Controller
rescue_from Cybertrain::RecordNotFound, with: :record_not_found
private
def record_not_found
render plain: "Not Found", status: :not_found
end
end
当前位置:你编写 app/, db/migrate/
生成文章脚手架
为什么一条命令写出整套 CRUD,Rails 风格。
cybertrain generate scaffold article title:string body:text
它会写出一个迁移、一个模型、一个控制器和五个视图(index、show、new、edit、_form),并把 resources :articles 插入 config/routes.rb。字段类型有 string、text、integer、float、boolean、date、datetime 和 references;默认为 string。快速上手中的 cybertrain g scaffold 是同一条命令。
已有文件永远不会被覆盖:每个文件都会报告为 create、identical 或 exist。
当前位置:你编写 config/routes.rb
设置根路由
为什么/ 应当显示文章列表。
# config/routes.rb
Cybertrain::Routes.draw do
root "articles#index"
resources :articles
end
路由在构建时读取:spin run gen 会把它们转换为 gen/routes.rb。cybertrain server、cybertrain db migrate 和 cybertrain build 会替你运行它。
当前位置:你编写 app/models/
添加模型验证
为什么与 Rails 指南一样,手动添加一个长度验证。
# app/models/article.rb -- only the first string field gets a default
# presence validation; add anything past that yourself.
class Article
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
end
class Article 没有写超类:Cybertrain::Model 超类、列和查找方法都来自 gen/models/article.rb。保存失败时会报告诸如 “Title can't be blank” 和 “Body is too short (minimum is 10 characters)” 这样的消息。
当前位置:你编写 app/controllers/
ArticlesController
为什么完整的 CRUD,连强参数和“先 flash 再重定向”都是 Rails 风格的,只有两处是 Spinel 处处强制要求的。
# app/controllers/articles_controller.rb (index/show/edit are as plain as
# Rails' own scaffold; update mirrors create)
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
def new_action # a `new` method would shadow ArticlesController.new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
flash[:notice] = "Article was successfully created."
redirect_to article_path(@article), status: :see_other
else
render :new, status: :unprocessable_entity
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body)
end
end
脚手架写出的完整控制器还包含 index、show、edit、update 和 destroy;上面的节选省略了它们。在 index 中写的是 @articles = Article.all.to_a:其中的 .to_a 是必需的。
实例变量是通过对 @ivar = 的词法扫描找到的,所以新增实例变量后要重新运行 cybertrain spin run gen;开发服务器会替你完成这一步。
当前位置:先构建时,后运行时 gen/ → build/bin/blog
生成、迁移、运行
为什么把 schema 和路由转换成 gen/,创建 SQLite 数据库,启动服务器。
cybertrain db migrate # spin run gen; spin run db -- migrate; spin run gen
cybertrain server # http://127.0.0.1:3000;修改 Ruby 后会重新构建,视图无需重新构建即可重新加载
spin run gen 读取 db/schema.rb 和 config/routes.rb,扫描 app/ 并写入 gen/;spin run db -- migrate 执行 db/migrate/*.rb 并重写 db/schema.rb。顺序很重要:第一次 spin run gen 把新迁移放进 gen/migrations.rb(bin/db.rb 会 require 它),第二次则根据重写后的 db/schema.rb 构建模型。
cybertrain server 会打印它运行的每条 spin 命令,然后是应用的启动横幅。第一次启动需要编译应用,所以要花一些时间:
=> Booting cybertrain 0.2.1
=> development environment (1 worker)
=> Watching app/, config/ and db/schema.rb for changes
* Listening on http://127.0.0.1:3000
Use Ctrl-C to stop
- 修改 schema、路由,或者控制器、模型的回调和实例变量之后,重新运行
cybertrain spin run gen并提交gen/。开发服务器会替你完成这一步。 - 用
cybertrain server 4000(或PORT=4000)更改端口。 spin run db要求在参数前加上--,cybertrain db会替你加上;cybertrain db status和其他数据库命令也是如此。
当前位置:你编写 db/migrate/, app/
生成评论脚手架
为什么references 类型会为你生成外键,关联正是由它推导出来的。
cybertrain generate scaffold comment commenter:string body:text article:references
cybertrain db migrate # 脚手架已为 comments 表写好了迁移
article:references 会添加 article_id 列、索引和外键——这足以让 Article/Comment 获得关联;不需要写 has_many/belongs_to,spin run gen 会从外键中读出关联。生成的 schema:
s.create_table "comments" do |t|
t.string "commenter"
t.text "body"
t.integer "article_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
s.add_index "comments", ["article_id"], name: "index_comments_on_article_id"
s.add_foreign_key "comments", "articles", column: "article_id"
脚手架会插入一行独立的 resources :comments,下一步会替换它。
当前位置:你编写 config/routes.rb
嵌套路由
为什么Rails 指南把评论嵌套在文章之下。
Cybertrain::Routes.draw do
root "articles#index"
# A nested block takes the mapper explicitly (Spinel's instance_eval
# trampoline only rewires `self` for the outermost block): `articles.resources`.
resources :articles do |articles|
articles.resources :comments, only: [:create, :destroy]
end
end
当前位置:你编写 app/controllers/
CommentsController
为什么只有两个动作可以访问,而父文章取自 URL(params[:article_id]),而不是 :id。
class CommentsController < ApplicationController
before_action :set_article
def create
@comment = Comment.new(comment_params)
@comment.article_id = @article.id
if @comment.save
flash[:notice] = "Comment was successfully created."
else
flash[:alert] = "Comment could not be saved: #{@comment.errors.full_messages.join(", ")}"
end
redirect_to article_path(@article), status: :see_other
end
def destroy
@comment = Comment.where(article_id: @article.id).find(params[:id])
@comment.destroy
flash[:notice] = "Comment was successfully destroyed."
redirect_to article_path(@article), status: :see_other
end
private
def set_article
@article = Article.find(params[:article_id])
end
def comment_params
params.require(:comment).permit(:commenter, :body)
end
end
然后给评论模型添加验证:
class Comment
validates :commenter, presence: true
validates :body, presence: true
end
这里的 Article#comments 是一个 Array,所以 Rails 的 @article.comments.find(params[:id]) 要写成 Comment.where(article_id: @article.id).find(params[:id])。不属于该文章的评论会返回 404。
当前位置:运行时 app/views/,开发时从磁盘读取,由 cybertrain build 嵌入
视图与局部视图
为什么文章页面列出它的评论,并在末尾放一个绑定到 [@article, @comment] 的表单。
首先,删除现在已无法访问的评论视图 index、show、new 和 edit,并在 ArticlesController#show 中提供一个空白的 @comment:
def show
@comment = Comment.new
end
然后在文章页面上渲染评论和表单:
<%# app/views/articles/show.html.erb %>
<% @article.comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
<%= render "comments/form" %>
<%# app/views/comments/_form.html.erb %>
<%= form_with(model: [@article, @comment]) do |f| %>
<%= f.label :commenter %> <%= f.text_field :commenter %>
<%= f.label :body %> <%= f.text_area :body %>
<%= f.submit %>
<% end %>
评论的局部视图,与示例中的一致:
<%# locals: (comment:) %>
<div class="comment">
<p>
<strong>Commenter:</strong>
<%= comment.commenter %>
</p>
<p>
<strong>Comment:</strong>
<%= comment.body %>
</p>
<%= button_to "Destroy comment", [@article, comment], method: :delete %>
</div>
button_to ... [@article, comment] 和 form_with(model: [@article, @comment]) 都通过同一条 [parent, child] 规则解析:新的子对象路由到嵌套集合(article_comments_path),已持久化的子对象路由到嵌套成员(article_comment_path)——这些路由都由上面的嵌套 resources 块生成到 gen/routes.rb 中。
当前位置:构建时 cybertrain spin test
测试
为什么minitest 和 RSpec 通过反射查找测试,而 AOT 编译器做不到这一点,因此 CyberTrain 提供了 Cybertrain::Test。
模型测试
require_relative "support/blog_test" # the app, BLOG and Cybertrain::Test
test "title must be present" do
article = Article.new(body: "a body long enough to pass length")
refute article.save
assert_includes article.errors.full_messages, "Title can't be blank"
end
断言:assert、refute、assert_equal、assert_nil、assert_includes、assert_raises("Class") { }、flunk。示例中还用到了 assert_response 和 assert_redirected_to。
完整的 HTTP 流程
require_relative "support/blog_test" # the app, BLOG and Cybertrain::Test
test "POST /articles creates an article and redirects to it" do
client = Cybertrain::Test::Client.new(BLOG)
client.get("/articles/new")
res = client.post("/articles", { "authenticity_token" => BlogTest.form_token(client),
"article[title]" => "Hello Rails",
"article[body]" => "I am on Rails! This is my first article." })
assert_redirected_to res, BlogTest.article_path(Article.last)
assert_response res, :see_other
end
Cybertrain::Test::Client 在进程内(不经过 socket)驱动应用,并带有 cookie jar,所以会话和 CSRF 的行为与在浏览器中一致。不带令牌的请求会得到 403。
运行测试
cybertrain spin test
cybertrain spin test 用锁定版本的 Spinel 运行 spin test;如果工具链已在 PATH 上(第 00 步),直接运行 spin test 效果相同。它会把每个 test/*.rb 编译成独立的程序,并将其输出(stdout 和 stderr 合在一起)与已提交的快照 test/<name>.rb.expected 进行比对。test/support/ 中的文件会被 require,而不会被运行。要写入快照:
cybertrain spin test --regen test/<name>.rb
rm -f build/test/<name>; cybertrain spin test test/<name>.rb; [ -x build/test/<name> ] && ./build/test/<name> > test/<name>.rb.expected 2>&1
--regen 根据 CRuby 的输出写入快照。通过 FFI 访问 SQLite 的测试无法在 CRuby 下运行,所以它们的快照来自编译后的二进制文件(上面的第二行)。只要快照过时,cybertrain spin test 就会以非零状态退出,所以它后面跟的是 ; 而不是 &&;[ -x ] 检查会在编译失败时跳过捕获,从而保留旧快照。无法在 CRuby 下运行的测试需要先有这个快照,cybertrain spin test 才能通过;其他没有快照的测试会与 CRuby 的输出进行比对。
当前位置:运行时 dist/blog
为部署而构建
为什么一条命令就能把应用构建到可部署的 dist/ 目录中,所有视图都已嵌入。
cybertrain build
这会写出 dist/:嵌入了所有视图的应用二进制文件,以及放在它旁边、供它使用的文件。它会运行 spin run gen -- --embed-views、spin build blog 和 spin run gen(最后这一步会恢复已提交的、空的 gen/views.rb,即使在失败或按下 Ctrl-C 之后也是如此),然后组装出:
dist/blog编译后的框架、控制器、模型和模板解释器,并嵌入了所有视图dist/public/静态资源和错误页面,也可以交给反向代理来提供dist/storage/创建时为空;默认存放 SQLite 文件(重新构建永远不会清空它)dist/tmp/创建时为空(重新构建永远不会清空它)
把 dist/ 复制到操作系统和 CPU 都相同的机器上(该二进制文件链接系统的 SQLite),然后在那里运行。构建出的二进制文件默认使用生产环境(可用 CYBERTRAIN_ENV 覆盖)。设置 CYBERTRAIN_SECRET_KEY_BASE;示例如下:
cd dist
export CYBERTRAIN_SECRET_KEY_BASE="$(openssl rand -hex 32)"
./blog migrate && ./blog
./blog migrate 执行编译进二进制文件的迁移,./blog db status 显示它们的状态;PORT 设置端口。重启前后请保持密钥不变:更改它会使所有会话失效。
Cybertrain::Config 首先读取环境变量;config/app.rb 中的设置会覆盖它们:
| 变量 | 属性 | 默认值 |
|---|---|---|
CYBERTRAIN_ENV | env | "development";由 cybertrain build 构建的二进制文件中为 "production"(也可以是 "test") |
PORT | port | 3000 |
CYBERTRAIN_DATABASE | database_path | storage/#{env}.sqlite3 |
CYBERTRAIN_SECRET_KEY_BASE | secret_key_base | 生产环境中必需;开发/测试环境会自动生成一个并写入 tmp/secret_key |
SPINEL_WORKERS | workers | 1 |
CYBERTRAIN_HOST | host | "127.0.0.1";0.0.0.0 监听所有网络接口(容器) |
CYBERTRAIN_SESSION_SAME_SITE | session_same_site | "Lax";也可以是 Strict 或 None(None 总会加上 Secure);其他任何值都会让服务器在启动时停止 |
CYBERTRAIN_SESSION_PARTITIONED | session_partitioned | false;1 或 true 会加上 Partitioned(以及 Secure) |
# config/app.rb
Cybertrain.configure do |c|
c.port = 3000
c.workers = 1
end
- 服务器使用纯 HTTP/1.1:如需 TLS 和 HTTP/2,请在前面放 nginx 或 Caddy。生产环境中会话 cookie 带有
Secure,所以要通过 HTTPS 提供服务(仅当生产环境确实使用纯 http 时,才在config/app.rb中设置c.session_secure = false)。 - 没有
CYBERTRAIN_SECRET_KEY_BASE时,生产环境会拒绝启动。 - 在生产环境中,异常会返回 500;而裸错误响应(路由器的纯文本 404、
head :not_found、render plain:)在public/<status>.html存在时会被该文件替换;cybertrain new会写出404.html和500.html。 SIGTERM会让服务器停止接受新连接,让正在处理的请求完成,并在它们完成后退出,或在服务器的drain_timeout(10 秒)之后退出。- 监视器和重新构建循环永远不会在生产环境中运行。正在运行的
cybertrain server会在cybertrain build持有tmp/cybertrain-build.lock期间跳过重新构建。
须知
为什么值得提前了解的限制和特殊之处,免得到时措手不及。
- 会话只能保存 String,存放在 HMAC 签名的 cookie 中。
- 仅支持 SQLite,通过 Spinel 的 FFI 访问。
- 没有回溯(backtrace):错误只显示类、消息、请求行以及模板名/行号。
- 视图中拼错的属性会渲染为空白,而不会抛出异常;这是一个已知的不足。
- 关联只来自 schema 中的外键;不支持
has_many :through、includes、pluck、dependent:、枚举、STI 或多态关联。 - 不支持
namespace、respond_to/格式、constraints或mount;render json:只有最基本的功能。 - 在脚手架生成的模型中,只有第一个字符串字段会获得默认的存在性验证。
- 生成器对
app/controllers和app/models使用词法扫描(正则表达式),而不是解析器。 - 视图在构建时嵌入:即使只修改了视图,也需要运行
cybertrain build并重启,改动才能到达生产环境。 - 在与运行机器相同的操作系统和 CPU 上构建
dist/:该二进制文件链接系统的 SQLite。 SPINEL_WORKERS默认为1。- 只测试过 Spinel
2026.09.12。
与 Rails 的差异
为什么README 中的完整表格,集中在一处。
| Rails | CyberTrain |
|---|---|
rails console | 没有控制台——Spinel 没有 eval |
| 修改代码,正在运行的应用会自动加载 | Ruby 代码需要重新构建;开发时 cybertrain server 会替你完成(重新构建、停止、execv) |
| 修改视图,无需重新加载 | 相同——开发时视图在每次请求时从磁盘解析 |
def new | def new_action(new 会遮蔽 Klass.new(ctx)) |
before_action { do_thing }(隐式 self) | before_action { |c| c.do_thing }——无法对存储的块使用 instance_exec,因此回调需要显式接收控制器/记录 |
resources :posts do resources :comments end | resources :posts do |posts| posts.resources :comments end——嵌套块需要显式接收 mapper |
| ERB 编译为方法;可以使用任何对象、任何方法 | 按照固定的语法/分派表解释执行 |
| 会话可以保存任何经 marshal 序列化的对象 | 会话值只能是 String,使用 HMAC 签名的 cookie |
| 多种数据库适配器 | 仅支持 SQLite,通过 FFI 访问 |
has_many :through、includes、pluck、dependent:、枚举、STI、多态关联 | 未实现;关联只来自 schema 中的外键 |
namespace、格式/respond_to、constraints、mount | 未实现——只有扁平的名称,仅支持 render json: |
| 异常时显示完整回溯 | 类、消息、请求行、模板名/行号——Spinel 不提供回溯 |
Rack 及其中间件,以及 Gemfile 中的任何 gem | 不兼容 Rack;只有一小组固定的中间件;使用 Spinel 自己的 spin-index,仅限能在其 Ruby 子集下编译的内容 |
| minitest / RSpec | Cybertrain::Test——基于反射的测试运行器无法在 AOT 编译下工作 |
| 认证、邮件、后台任务、ActionCable、资源管道、i18n | 未构建(不在 MVP 范围内) |
此表并不完整。