Apache rivet 在 Ubuntu 22.04 集成使用指南

以 Ubuntu 22.04 + Apache 2.4.52 + Rivet 3.3.1 实测环境演示 Apache Rivet 的集成使用:模板标签与控制结构、请求参数与环境变量读取、GET/POST 表单处理、Session 会话全链路搭建,以及一个可运行的 SQLite 留言板小应用。

Apache rivetApache rivetTclhttpdUbuntu 22.042026/10/16

Apache rivet 在 Ubuntu 22.04 集成使用指南

目标:在已安装 Apache Rivet 的 Ubuntu 22.04 服务器上,把模板语法、请求参数处理、表单、会话(Session)等核心能力逐项跑通,最终交付一个基于 SQLite 的完整留言板小应用。全部代码与回显均为实机执行结果,可直接照抄复现。实验目录统一使用 /var/www/html,管理脚本放 /root。

一、环境信息

  • 操作系统:Ubuntu 22.04 LTS(jammy,x86_64)
  • Apache:2.4.52(event MPM),已按《Ubuntu 22.04 安装 Apache rivet 指南》完成 mod_rivet 安装
  • Rivet:3.3.1(源码编译,模块 /usr/lib/apache2/modules/mod_rivet.so,Tcl 运行库 /usr/local/apache2/lib/rivet3.3.1/)
  • Tcl:8.6.12;扩展依赖:itcl3 3.4.3、libsqlite3-tcl 3.37.2、tcllib 2.0
  • 基线验证:
apache2 -v | head -1
Server version: Apache/2.4.52 (Ubuntu)
curl -s -o /dev/null -w '%{http_code}\n' http://localhost/hello.rvt
200

💡 技术拓展:Rivet 3.x 的命令默认收敛在 ::rivet 命名空间中(ImportRivetNS 默认关闭),本文统一使用 ::rivet:: 限定名写法——这与网上常见的 2.x 教程(裸写 var、include)不同。若希望兼容旧写法,可在 httpd 配置中加 RivetServerConf ImportRivetNS true,见《Apache rivet 配置参数详解》实测记录。

二、前置条件

  1. mod_rivet 已加载:apachectl -M | grep rivet 输出 rivet_module (shared);
  2. .rvt/.tcl 类型已映射(AddType application/x-httpd-rivet .rvt 与 AddType application/x-rivet-tcl .tcl);
  3. 扩展依赖齐备(Session 实测需要):
echo 'package require Itcl; package require sqlite3; package require md5; puts ok' | tclsh
ok
  1. 一个可写的运行目录(Session 数据库与上传目录用):
mkdir -p /var/lib/rivet
chown -R www-data:www-data /var/lib/rivet

⚠️ 实测坑点:httpd 子进程以 www-data 运行,sqlite 建库后若属主是 root,Session 首次写库即报 attempt to write a readonly database;另外 sqlite 写入还需要在同目录创建 -journal 临时文件,因此库文件所在目录也要 www-data 可写(本文曾把库放 /var/www/html 下复现过该问题,迁到 /var/lib/rivet 解决)。

三、模板语法:标签、变量与控制结构

3.1 两类标签

  • <? Tcl 代码 ?>:执行代码,无输出;
  • <?= 表达式 ?>:把表达式的值插入页面。

最小验证页:

cat > /var/www/html/min.rvt <<'EOF'
<? set t "X" ?><h1><?= $t ?></h1>
EOF
curl -s http://localhost/min.rvt
<h1>X</h1>

3.2 HTML 静态区是"字面文本"

模板中标签之外的 HTML 会被编译成 puts -nonewline "..." 字符串,$、[、]、{}、"、\ 一律自动转义为字面文本。实测把 [clock format ...] 写在 HTML 区:

<p>now: [clock format [clock seconds]]</p>
curl -s http://localhost/hello.rvt | grep 'now:'
<p>now: [clock format [clock seconds]]</p>

输出保持原样,并未被求值。需要执行命令替换必须放入标签内:

<p>server time: <? puts [clock format [clock seconds] -format "%Y-%m-%d %H:%M:%S"] ?></p>
<p>server time: 2026-10-16 04:07:12</p>

💡 技术拓展:这一设计让模板生成脚本的字符串字面量恒定安全,代价是"HTML 里不能裸写 [...]"。若希望整页放弃模板、用纯 Tcl 生成 HTML,可改用 .tcl 后缀(application/x-rivet-tcl),脚本内自由 puts,实测:echo 'puts -nonewline "<h1>t=[info patchlevel]</h1>"' > info.tcl && curl -s localhost/info.tcl 输出 <h1>t=8.6.12</h1>。

3.3 控制结构跨标签混排

foreach/if/while 的大括号可横跨多个标签,标签外的 HTML 属于循环/分支体。实测:

cat > /var/www/html/ctrl.rvt <<'EOF'
<? set fruits {apple banana orange} ?>
<ul>
<? foreach f $fruits { ?>
  <li><?= $f ?></li>
<? } ?>
</ul>
<? if {[llength $fruits] > 2} { ?>
<p>more than two fruits</p>
<? } else { ?>
<p>two or fewer</p>
<? } ?>
EOF
curl -s http://localhost/ctrl.rvt
<ul>
  <li>apple</li>
  <li>banana</li>
  <li>orange</li>
</ul>
<p>more than two fruits</p>

while 生成行内文本(标签内 puts 不经 HTML 转义,直接写流):

<? set count 0; while {$count < 3} { puts -nonewline "n=$count "; incr count } ?>
n=0 n=1 n=2

四、请求参数与环境变量

4.1 var 命令族(3.x 子命令风格)

var/var_qs/var_post 共用同一组子命令:get(取值,带默认值)、exists、list(同名多值)、names(全部键名)、number(键数)、all。区别只在取值范围:var 全部(GET+POST)、var_qs 仅查询串、var_post 仅请求体。

实测(?GET_name=Tom&a=1):

cat > /var/www/html/api1.rvt <<'EOF'
<? set out {} ?>
<? lappend out "var get GET_name = [::rivet::var get GET_name default-Tom]" ?>
<? lappend out "var exists nosuch = [::rivet::var exists nosuch]" ?>
<? lappend out "var names = [::rivet::var names]" ?>
<? lappend out "var number = [::rivet::var number]" ?>
<? foreach item $out { puts "$item<br/>" } ?>
EOF
curl -s 'http://localhost/api1.rvt?GET_name=Tom&a=1'
var get GET_name = Tom<br/>
var exists nosuch = 0<br/>
var names = GET_name a<br/>
var number = 2<br/>

⚠️ 版本差异:Rivet 2.x 的 var GET_name 直取值写法在 3.3 中报错(实测:bad option: must be one of 'get, list, names, number, all'),3.x 必须写 var get。检查变量是否存在用 var exists,不要找 var_exists——该命令在 3.3.1 中不存在(实测 invalid command name "::rivet::var_exists")。

4.2 环境变量与环境信息

load_env 把 CGI 环境装载进 ::request::env 数组;单变量也可用 ::rivet::env 命令直取:

cat > /var/www/html/env.rvt <<'EOF'
<? ::rivet::load_env ?>
<? foreach k {SERVER_NAME REQUEST_METHOD QUERY_STRING SERVER_SOFTWARE} {
     puts "$k = $::request::env($k)<br/>" } ?>
EOF
curl -s 'http://localhost/env.rvt?a=1&b=2'
SERVER_NAME = localhost<br/>
REQUEST_METHOD = GET<br/>
QUERY_STRING = a=1&b=2<br/>
SERVER_SOFTWARE = Apache/2.4.52 (Ubuntu)<br/>

注意:不要写 $env(SERVER_NAME)——直接读全局 env 数组会命中 Rivet 注册的 read trace 而报 no such variable(实测错误信息:read trace on "env(SERVER_NAME)")。Cookie 头、UA 等请求头经 load_headers 落入 ::request::headers 数组(实测键集含 Accept、Host、User-Agent、自定义头等)。

五、表单处理

5.1 GET 表单

cat > /var/www/html/getform.rvt <<'EOF'
<html><body>
<? if {[::rivet::var exists GET_name]} { ?>
<h2>Hello <?= [::rivet::var get GET_name] ?>, you are <?= [::rivet::var get GET_age] ?> years old</h2>
<? } ?>
<form method="GET" action="/getform.rvt">
Name: <input name="GET_name"><br>
Age: <input name="GET_age"><br>
<input type="submit" value="go">
</form>
</body></html>
EOF
curl -s 'http://localhost/getform.rvt?GET_name=Tom&GET_age=25' | sed -n '/<h2>/p'
<h2>Hello Tom, you are 25 years old</h2>

5.2 POST 表单与文件上传

POST 取值用 var_post get;文件上传用 upload 命令族(exists/filename/size/save/data 等),服务端目录由 RivetServerConf UploadDirectory 指定:

cat > /var/www/html/upload.rvt <<'EOF'
<?
if {[::rivet::upload exists getfile]} {
    set name [::rivet::upload filename getfile]
    set size [::rivet::upload size getfile]
    puts "received file: $name, $size bytes<br>"
    ::rivet::upload save getfile /var/lib/rivet/uploads/$name
    puts "saved to UploadDirectory<br>"
}
?>
<form method="POST" enctype="multipart/form-data" action="/upload.rvt">
<input type="file" name="getfile">
<input type="submit" value="上传">
</form>
EOF
mkdir -p /var/lib/rivet/uploads
echo "hello rivet upload test" > /tmp/ufile.txt
curl -s -F getfile=@/tmp/ufile.txt http://localhost/upload.rvt
received file: ufile.txt, 24 bytes<br>
saved to UploadDirectory<br>
cat /var/lib/rivet/uploads/ufile.txt
hello rivet upload test

💡 技术拓展:upload 的默认行为受 UploadMaxSize(字节上限,超限直接报错页)与 UploadFilesToVar(上传内容暂存为内存变量还是临时文件)控制;本文实测 2 MB 上限(UploadMaxSize 2000000)内上传 24 字节文件成功。相关指令的默认值与调优见《Apache rivet 配置参数详解》。

六、Session 会话:全链路搭建与实测

Rivet 3.3.1 自带 Session 1.1(itcl 类,packages/session/session-class.tcl),数据经 DIO 数据库抽象层持久化。搭建分三步:建库、初始化对象、每请求激活。

6.1 建会话数据库(SQLite)

按包内 DDL(session-create-sqlite.sql)建两张表:

cat > /root/mkdb.tcl <<'EOF'
package require sqlite3
sqlite3 db /var/lib/rivet/sessions.db
db eval {
CREATE TABLE rivet_session (
    ip_address          varchar(16) default NULL,
    session_start_time  varchar(24) default NULL,
    session_update_time varchar(24) default NULL,
    session_id          varchar(64) NOT NULL default '',
    PRIMARY KEY (session_id)
);
CREATE TABLE rivet_session_cache (
    session_id      varchar(128)    default NULL PRIMARY KEY ON CONFLICT FAIL,
    package_        varchar(128)    default NULL,
    key_            varchar(256)    default NULL,
    data            varchar(4096)   default NULL,
    CONSTRAINT session_cleanup FOREIGN KEY (session_id) REFERENCES rivet_session(session_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX rvt_sess_cache_idx ON rivet_session_cache ( session_id, package_, key_ );
}
db close
puts OK
EOF
tclsh /root/mkdb.tcl
chown -R www-data:www-data /var/lib/rivet
OK

6.2 Apache 配置:初始化与激活

cat > /etc/apache2/conf-available/rivet-session.conf <<'EOF'
RivetServerConf ChildInitScript "package require Itcl; package require DIO; package require Session; ::DIO::handle Sqlite DIO -db /var/lib/rivet/sessions.db; Session SESSION -dioObject DIO -entropyFile /dev/urandom -entropyLength 10 -debugMode 0"
<Directory "/var/www/html">
    RivetDirConf BeforeScript "SESSION activate"
</Directory>
EOF
a2enconf rivet-session
apachectl -k restart

三个配置的含义与实测依据:

配置作用实测说明
ChildInitScript每个 httpd 子进程启动时执行一次在此创建 DIO 句柄与 SESSION 对象;缺 package require Itcl 会报 can't find package Itcl
::DIO::handle Sqlite DIO -db ...DIO Sqlite 句柄实测选项名是 -db(不是 -database,写错报 unknown option "-database")
BeforeScript "SESSION activate"每个请求先激活会话从 cookie 识别会话,无则新建并下发 Set-Cookie: rivetSession=...

💡 技术拓展:Session 的会话 ID 由 md5::md5 -hex(依赖 tcllib)对熵源(-entropyFile /dev/urandom)生成,实测形如 A94D28BC96652354A4712C946EF5BE48 的 32 位十六进制串;-debugMode 1 时会话对象会把调试信息直接打到响应流(首请求可见 activate 过程日志),生产建议关闭。

6.3 会话读写实测

测试页(fetch 缓存值):

cat > /var/www/html/sess.rvt <<'EOF'
<? set v [SESSION fetch webapp visits]
   if {$v == ""} { set v 0 } ?>
<p>session id: <?= [SESSION id] ?></p>
<p>is_new_session: <?= [SESSION is_new_session] ?></p>
<p>cached visits value: <?= $v ?></p>
EOF
rm -f /tmp/cj.txt
curl -s -c /tmp/cj.txt http://localhost/sess.rvt
<p>session id: A94D28BC96652354A4712C946EF5BE48</p>
<p>is_new_session: 1</p>
<p>cached visits value: 0</p>

带 cookie 的第二次请求(同一会话):

curl -s -b /tmp/cj.txt http://localhost/sess.rvt
<p>session id: A94D28BC96652354A4712C946EF5BE48</p>
<p>is_new_session: 0</p>
<p>cached visits value: 1</p>

is_new_session 从 1 变 0、会话 ID 保持一致、缓存值被读出——会话识别与恢复链路打通。SESSION 对象的常用方法(源码 session-class.tcl 实测):id(会话号)、is_new_session、store pkg key value、fetch pkg key、delete_session、do_garbage_collection。

6.4 3.3.1 的两个上游缺陷与修复(重要)

实测发现并修复,未修复时 Session 完全不可用于会话恢复场景。

缺陷一:rivet-tcl/load_cookies.tcl 解析不出任何 cookie。根因:先把 k=v 按 = split 成列表,再对列表值执行 string first = $pair,列表的字符串表示里不含 =,永远返回 -1 后 continue。实测证据(页面内执行 <? array set ck {}; ::rivet::load_cookies ck; puts [array get ck] ?>,请求头带 Cookie: rivetSession=E8E6...; foo=bar):

load_cookies = 

而 ::rivet::env HTTP_COOKIE 能取到完整 cookie 串(rivetSession=E8E6...; foo=bar),证明输入无问题、是解析代码缺陷。一行修复(实测有效):

cp /usr/local/apache2/lib/rivet3.3.1/rivet-tcl/load_cookies.tcl \
   /usr/local/apache2/lib/rivet3.3.1/rivet-tcl/load_cookies.tcl.orig
python3 - <<'PYEOF'
path = '/usr/local/apache2/lib/rivet3.3.1/rivet-tcl/load_cookies.tcl'
src = open(path).read()
old = '''            set pair [split [string trim $pair] "="]'''
new = '''            set pair [string trim $pair]'''
assert old in src, 'pattern not found'
open(path, 'w').write(src.replace(old, new))
print('patched')
PYEOF
apachectl -k restart
patched

缺陷二:packages/dio/dio.tcl 的 update 方法把链接变量 row_a 传给 build_update_query 时写成了不存在的 array,导致生成 UPDATE rivet_session_cache SET WHERE ...(SET 子句为空)的非法 SQL,会话缓存第二次写入(UPDATE 路径)报错。同样一行修复:

cp /usr/local/apache2/lib/rivet3.3.1/packages/dio/dio.tcl \
   /usr/local/apache2/lib/rivet3.3.1/packages/dio/dio.tcl.orig
python3 - <<'PYEOF'
path = '/usr/local/apache2/lib/rivet3.3.1/packages/dio/dio.tcl'
lines = open(path).read().split('\n')
needle = '        set req [build_update_query array $fields $myTable]'
i = lines.index(needle)          # 第一处 = update 方法内
lines[i] = '        set req [build_update_query row_a $fields $myTable]'
open(path, 'w').write('\n'.join(lines))
print('patched at line', i + 1)
PYEOF
apachectl -k restart
patched at line 511

修复后以第七章的留言板做全流程回归:同一会话号连续两次 POST,is_new 变 0、lastuser 从 Tom 更新为 Jerry(store 的 INSERT 与 UPDATE 两条路径均实测通过,输出见 7.2 节)。

⚠️ 升级提示:这两个缺陷在 3.2 线是否同样存在未逐版验证;每次升级 Rivet 后应重跑 6.3/6.4 与 7.2 的会话回归测试,或把补丁纳入部署流水线。

七、完整小应用:SQLite 留言板

综合运用:模板控制结构、POST 表单、Session 记住最近留言人、SQLite 持久化、HTML 转义防注入。

7.1 业务库与页面

cat > /root/mkboard.tcl <<'EOF'
package require sqlite3
sqlite3 db /var/lib/rivet/board.db
db eval {
CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    message TEXT NOT NULL,
    created TEXT DEFAULT (datetime('now'))
);
}
db close
puts OK
EOF
tclsh /root/mkboard.tcl
chown www-data:www-data /var/lib/rivet/board.db
OK
cat > /var/www/html/board.rvt <<'EOF'
<?
# 读取 POST 数据(若提交了表单)
set name [::rivet::var_post get name ""]
set message [::rivet::var_post get message ""]
set saved 0
if {$name ne "" && $message ne ""} {
    package require sqlite3
    sqlite3 ::board /var/lib/rivet/board.db
    ::board eval {INSERT INTO messages(name, message) VALUES($name, $message)}
    ::board close
    set saved 1
    SESSION store webapp lastuser $name
}
?>
<html>
<head><title>Rivet 留言板</title></head>
<body>
<h1>Rivet 留言板</h1>
<p>当前会话: <?= [SESSION id] ?> (is_new: <?= [SESSION is_new_session] ?>)</p>
<? if {$saved} { ?>
<p style="color:green">留言已保存。上次留言用户(已存会话): <?= [SESSION fetch webapp lastuser] ?></p>
<? } ?>
<form method="POST" action="/board.rvt">
<p>姓名: <input name="name" value="<?= [::rivet::var get name ""] ?>"></p>
<p>留言: <textarea name="message" rows="3" cols="40"></textarea></p>
<p><input type="submit" value="提交"></p>
</form>
<h2>已有留言</h2>
<ul>
<?
package require sqlite3
sqlite3 ::reader /var/lib/rivet/board.db
::reader eval {SELECT id, name, message, created FROM messages ORDER BY id DESC LIMIT 10} row {
    puts "<li>#$row(id) [::rivet::escape_sgml_chars $row(name)]: [::rivet::escape_sgml_chars $row(message)] ($row(created))</li>"
}
::reader close
?>
</ul>
</body>
</html>
EOF

实现要点:

  • SQLite 的 Tcl 绑定支持花括号 SQL 中的 $变量 参数绑定(VALUES($name, $message)),避免手工拼串;
  • 展示层用 escape_sgml_chars 做 HTML 转义——实测提交 Nice <framework> 后列表渲染为 Nice &lt;framework&gt;,注入内容被中和;
  • SESSION store webapp lastuser $name 记录最近留言人,跨请求读取。

7.2 全流程实测

第一次访问(新会话)与提交留言:

rm -f /tmp/cj3.txt
curl -s -c /tmp/cj3.txt -X POST -d 'name=Tom' -d 'message=Hello from Rivet!' http://localhost/board.rvt | sed -n '/当前会话/p;/已保存/p'
<p>当前会话: 9D3F4FE13C710DA23E0B1FFC09358FEE (is_new: 1)</p>
<p style="color:green">留言已保存。上次留言用户(已存会话): Tom</p>

第二次带 cookie 提交(会话保持、lastuser 更新):

curl -s -b /tmp/cj3.txt -X POST -d 'name=Jerry' -d 'message=Nice <framework>' http://localhost/board.rvt | sed -n '/当前会话/p;/已保存/p'
<p>当前会话: 9D3F4FE13C710DA23E0B1FFC09358FEE (is_new: 0)</p>
<p style="color:green">留言已保存。上次留言用户(已存会话): Jerry</p>

留言列表(转义生效):

curl -s http://localhost/board.rvt | sed -n '/已有留言/,$p'
<h2>已有留言</h2>
<ul>
<li>#2 Jerry: Nice &lt;framework&gt; (2026-10-16 04:21:07)</li>
<li>#1 Tom: Hello from Rivet! (2026-10-16 04:20:41)</li>
</ul>
</body>
</html>

7.3 页面组织:include 与 makeurl

页头片段复用与站点内 URL 构造(include 引入模板片段;makeurl 基于当前请求拼接,避免硬编码域名):

cat > /var/www/html/header.inc <<'EOF'
<div class="header">站点页头(被 include 引入)</div>
EOF
cat > /var/www/html/incdemo.rvt <<'EOF'
<? ::rivet::include /var/www/html/header.inc ?>
<p>正文内容</p>
EOF
curl -s http://localhost/incdemo.rvt
echo '--- makeurl ---'
curl -s http://localhost/mkurl.rvt
<div class="header">站点页头(被 include 引入)</div>
<p>正文内容</p>
--- makeurl ---
makeurl = http://localhost/board.rvt

abort_page 中止请求渲染(实测页面正文不再输出,仅保留目录级脚本的前后缀):

curl -s http://localhost/abort.rvt
<!--B><!--D>

八、生产化注意事项

  1. 模板编码:非 ASCII 内容(如中文)直接写入 .rvt 即可输出,实测中文标题/留言正常;但响应头 Content-Type: text/html;charset=UTF-8 需在 httpd 层统一设置(AddDefaultCharset UTF-8)。
  2. 命名空间策略:本文全程 ::rivet:: 限定名;若团队从 2.x 迁移,可在服务器级开 ImportRivetNS true 平滑过渡,但新代码建议限定名(避免与应用 Tcl 库冲突)。
  3. 会话安全:rivetSession cookie 未强制 HttpOnly/Secure 字段(实测 Set-Cookie: rivetSession=...),建议在 httpd 层用 Header always edit Set-Cookie ... 追加属性,或结合 TLS 终端策略统一加固。
  4. 错误页统一:RivetServerConf ErrorScript 指向纯 Tcl 处理脚本(source /var/www/html/errhandler.tcl),实测可接管除零等异常并输出 $::errorInfo;注意不能指向 .rvt 模板文件(会被当 Tcl 执行而失败)。
  5. 上游缺陷跟进:6.4 节两个补丁要纳入版本管理,升级 Rivet 后回归验证 Session 全链路。

九、参考链接