如何在 Symfony Messenger worker 中识别验证码(PHP)

要在 Symfony Messenger 中识别验证码,就派发一条携带页面地址的小消息,并在它的处理程序里完成识别。把队列保存在一个 SQLite 文件里,这样 Windows 上的 worker 就不需要 Redis 或 RabbitMQ。这套 Symfony Messenger 验证码识别方案有四个陷阱,本指南会逐一讲到。那个看起来就是为重试而设计的异常,会忽略 max_retries。Messenger 没有针对单条消息的时限。如果重新投递超时比最慢的那次识别更短,第二个 worker 就会接手这条消息并再识别一次。而且没有 pcntl 扩展(Windows 版 PHP 没有它)时,按 Ctrl+C 会在识别进行到一半时杀掉 worker。
你需要什么
- 在 Windows 电脑上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。
- PHP 8.4.1 或更高版本,以及 Composer。Symfony 8.1 要求这个 PHP 版本,而且下面的处理程序用到了 PHP 8.4 的 HTML 解析器。所有内容都在 Windows 11 上运行过,使用 PHP 8.4.15、Symfony 8.1.8、doctrine/dbal 4.5.0 和 capskip/capskip 1.3.0。
- 在 php.ini 中启用三个 PHP 扩展:curl、pdo_sqlite 和 openssl。只启用前两个时,worker 也能针对普通 http 测试页面正常运行,但获取 https 页面需要 openssl,Composer 下载包时同样需要它。
- 不需要 Redis、RabbitMQ 或数据库服务器。Doctrine transport 把队列保存在一个 SQLite 文件里。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的 worker 就能通过 API 调用它。两种模式的设置位置都是 连接设置.
下面的测量数据来自同一台电脑上的一个模拟 CapSkip 和一个模拟登录页面(都是为测试搭建的替身),配合真实的 PHP SDK 和真实的 Symfony 组件使用。属于 SDK 和 Messenger 的时间(比如 120 秒),在你自己的站点上同样成立。模拟站点的接受和拒绝规则,则只属于这个模拟站点本身。
第 1 步:创建项目
先检查 php -v,因为本指南中的每条命令用的都是 PATH 里的那个 PHP。如果 PATH 里排在前面的是较旧的 PHP(比如 XAMPP 自带 8.0),composer create-project 不会停下来。它会打印一条提示,说最新的 skeleton 需要 PHP 8.4,然后照样安装一个旧版本:在 PHP 8.0.30 上装出来的是 Symfony 6.0。开始之前,先把 PHP 8.4 所在的文件夹放到这个窗口 PATH 的最前面。
# PowerShell, with PHP 8.4 first on the PATH composer create-project symfony/skeleton cs-worker cd cs-worker composer require symfony/messenger symfony/doctrine-messenger doctrine/doctrine-bundle symfony/http-client capskip/capskip --no-scripts Remove-Item config\packages\doctrine.yaml, config\packages\messenger.yaml
Composer 可能会询问是否要包含 recipe 提供的 Docker 配置。回答 n,因为默认选项会添加一个 compose.yaml,里面是一个这套方案用不到的 PostgreSQL 服务。另外还有两个细节值得注意。Doctrine recipe 写入的 doctrine.yaml 要求安装 doctrine/orm 包,而这个项目并不使用它,所以 cache:clear 会失败,并报错说需要 doctrine/orm。Composer 的 no-scripts 标志会跳过这一步,最后一行则删除这两个 recipe 写入的 YAML 文件。第 2 步会用 PHP 文件替换它们,Symfony 会同样地从 config/packages 中加载这些文件。
第 2 步:让 transport 指向一个 SQLite 文件
把 recipe 添加到 .env 里的 DATABASE_URL 和 MESSENGER_TRANSPORT_DSN 两行替换成下面这些,并补上其余几行。像密码这样的真实值要放在 .env.local 里,Symfony skeleton 会把它排除在 Git 之外。
MESSENGER_TRANSPORT_DSN=doctrine://default DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" CAPSKIP_HOST=127.0.0.1 CAPSKIP_PORT=8080 CAPSKIP_API_KEY=YOUR_API_KEY SITE_PASSWORD=YOUR_PASSWORD
SQLite 路径特意以 %kernel.project_dir% 开头。像 sqlite:///var/data.db 这样的相对路径,在命令从 C:\ 运行时会失败,报 unable to open database file。如果从一个自带 var 目录的文件夹运行,它会悄悄地在那里创建第二个数据库,于是两条命令看到的是两个队列。处理程序通过 Autowire 读取 CapSkip 的变量,因为 SDK 不会自己读取它们。只有在 CapSkip 中开启了 API key validation 时,CAPSKIP_API_KEY 才有意义。
<?php
// config/packages/doctrine.php (replaces the recipe's doctrine.yaml, which asks for doctrine/orm)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
'doctrine' => [
'dbal' => ['url' => '%env(resolve:DATABASE_URL)%'],
],
]);transport 的设置放在第二个文件里。
<?php
// config/packages/messenger.php (replaces the recipe's messenger.yaml)
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
'framework' => [
'messenger' => [
'failure_transport' => 'failed',
'transports' => [
'async' => [
'dsn' => '%env(MESSENGER_TRANSPORT_DSN)%',
// Longer than the slowest handler, or a second worker takes the message over.
'options' => ['redeliver_timeout' => 900],
'retry_strategy' => ['max_retries' => 3, 'delay' => 30000, 'multiplier' => 2, 'max_delay' => 300000],
],
'failed' => 'doctrine://default?queue_name=failed',
],
'routing' => ['App\Message\SolveLogin' => 'async'],
],
],
]);起关键作用的是三项设置。重试策略在各次尝试之间分别等待大约 30、60 和 120 秒,给 CapSkip 留出恢复的时间。Messenger 会让每个延迟上下浮动最多 10%,一次测试记录到的是 29.4、57.0 和 117.9 秒。默认设置会重试三次,起始间隔为一秒。failed transport 比看上去更重要:没有它,用完最后一次重试的消息就会被删除并丢失。而 redeliver_timeout 会在下面单独的一节里说明。
第 3 步:编写消息
消息携带的是登录所需的输入,不包含识别工具产生的任何东西。根据 Google 的文档,reCAPTCHA token 的有效期是两分钟,而且只能使用一次。模拟站点会强制执行这条规则。一条携带了 token 的消息,立即运行时被接受,130 秒后再运行时则被拒绝。一条只携带页面地址的消息会重新识别,并被接受。所以处理程序会连续地获取页面、识别并提交。
<?php
// src/Message/SolveLogin.php
namespace App\Message;
final class SolveLogin
{
public readonly string $jobId;
public function __construct(
public readonly string $siteUrl,
public readonly string $username,
) {
// Stays the same across retries: the message is serialized once and re-sent as it is.
$this->jobId = bin2hex(random_bytes(8));
}
}作业 id 在消息构建时只创建一次,并且在多次重试中保持不变,因为 Messenger 重新发送的是同一条序列化后的消息。本指南接近结尾的台账就依赖这一点。Messenger 用 PHP 的 serialize 存储消息,所以在消息还在队列中时重命名这个类,会让队列里的这些行失效。
第 4 步:决定重试什么
CapSkip 会抛出四种异常类型,它们并不是都值得再试一次。ValidationException 表示调用本身有误,下次还会以同样的方式失败。ApiException 只有在 ERROR_CAPTCHA_UNSOLVABLE 时才值得重试,这是 CapSkip 表示这次识别没有成功的方式。NetworkException(CapSkip 没有运行)和 TimeoutException 值得重试。如果你在 CapSkip 中修改过 Error Replacement 文本,对 ERROR_CAPTCHA_UNSOLVABLE 的匹配就看不到它,这些失败也就不会被重试。
<?php
// src/CapSkipFailure.php
namespace App;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
final class CapSkipFailure
{
public static function toMessenger(CapSkipError $e): \Throwable
{
// A bad parameter or an API error other than "unsolvable" will fail the same way next time.
$retry = !$e instanceof ValidationException
&& (!$e instanceof ApiException || str_contains($e->getMessage(), 'ERROR_CAPTCHA_UNSOLVABLE'));
// A plain exception is retried by the retry strategy, which also decides the delay and the limit.
return $retry
? new \RuntimeException($e->getMessage(), 0, $e)
: new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
}
}普通异常会由重试策略重试,最多重试 max_retries 次。UnrecoverableMessageHandlingException 会跳过重试,直接进入 failed transport:在测试中,它只尝试了一次,并记录了 Removing from transport after 0 retries。
这里不要用 RecoverableMessageHandlingException,尽管它的名字看起来像是为此而设。在 Symfony 8.1.8 中,除非你把 false 作为它的第五个参数传入,否则无论 max_retries 怎么设置,它都会重试。在一次测试中,max_retries 为 3,它在 14 秒内尝试了 7 次,之后消息仍然留在队列中。在 7.4 的源码里,它没有这个参数,并且总是重试。普通异常在两个版本上的表现相同。
第 5 步:编写处理程序
处理程序用 Symfony 的 HttpClient 获取登录页面,读取 CSRF token 和 sitekey,向 CapSkip 请求一个 token,然后提交表单。在这套配置下,HttpClient 没有 cookie jar,所以处理程序会手动把第一个 Set-Cookie 响应头复制到提交请求中。在 Django 上,那就是与 CSRF token 配套的 cookie;如果你的站点设置了更多 cookie,就把它们拼接起来。它还会发送 Referer,因为有些框架(比如 HTTPS 下的 Django)会拒绝没有 Referer 的表单提交。请把路径和字段名换成你自己站点使用的。
<?php
// src/MessageHandler/SolveLoginHandler.php
namespace App\MessageHandler;
use App\CapSkipFailure;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final class SolveLoginHandler
{
private CapSkip $solver;
public function __construct(
private HttpClientInterface $http,
#[Autowire(env: 'CAPSKIP_HOST')] string $host,
#[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
#[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
#[Autowire(env: 'SITE_PASSWORD')] private string $password,
) {
$this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
}
public function __invoke(SolveLogin $message): void
{
$url = $message->siteUrl . '/login';
$page = $this->http->request('GET', $url);
$cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
$doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
$csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
$sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');
try {
$token = $this->solver->recaptcha($sitekey, $url)['code'];
} catch (CapSkipError $e) {
throw CapSkipFailure::toMessenger($e);
}
$reply = $this->http->request('POST', $url, [
'headers' => ['Referer' => $url, 'Cookie' => $cookie],
'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
'password' => $this->password, 'g-recaptcha-response' => $token],
]);
if ($reply->getStatusCode() >= 400) {
throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
}
// Save the result here (your own code).
}
}recaptcha 方法返回一个数组,其中的 code 项就是 token。对于其他验证码类型,调用对应的 SDK 方法,其余部分保持不变。 用 PHP 识别 reCAPTCHA v2 的指南 介绍了同一个调用在队列之外的写法。
处理程序把 400 及以上的状态码视为失败。如果站点对错误密码返回状态 200 并再次显示登录表单,就需要对页面本身做一个检查,否则后面的台账会把这个作业记为已完成。被拒绝的登录在这里抛出的是普通异常,所以它和其他失败一样会被重试,一个错误密码就要花掉四次识别。对于你确定是最终结果的任何响应,都应抛出 UnrecoverableMessageHandlingException。
第 6 步:派发一条消息并启动 worker
一个小的控制台命令会把一条消息放入队列。Symfony 8 的控制台命令可以是普通的可调用类(invokable class),就像这里这样。
<?php
// src/Command/SolveLoginCommand.php
namespace App\Command;
use App\Message\SolveLogin;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(name: 'app:solve-login', description: 'Queue one SolveLogin message')]
final class SolveLoginCommand
{
public function __construct(private MessageBusInterface $bus)
{
}
public function __invoke(#[Argument] string $siteUrl, #[Argument] string $username): int
{
$this->bus->dispatch(new SolveLogin($siteUrl, $username));
return 0;
}
}把一次登录放入队列并启动 worker。transport 会在第一次使用时创建它的表。把 https://example.com 换成你自己的、带有 reCAPTCHA 的页面:用这个占位地址时,页面获取会失败,作业就会进入重试循环。
php bin/console app:solve-login https://example.com YOUR_USERNAME php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M
不加 -vv 时,worker 对每条消息几乎什么都不打印,而且 Symfony 的控制台日志没有时间戳,所以如果需要,就自己添加。内存限制可以放心使用:worker 只占用了大约 12 MB。时限则需要一个警告,下面马上讲到。
单条消息没有时限
Messenger 没有针对单条消息的超时。worker 的时限选项只在两条消息之间检查,所以它无法中途打断一个处理程序:在队列里有两条消息、一次识别耗时 20 秒、时限为 5 秒的情况下,worker 运行了 23.1 秒,处理完第一条消息,第二条仍留在队列中。设置 max_execution_time 同样没用。命令行 PHP 会忽略 php.ini 里的这个值,而用 -d 选项或 set_time_limit 强制设置它,只是在被阻塞的调用已经返回之后才产生一个致命错误,那已经是 120.1 秒时了。
真正给一次卡住的识别划定上限的,是 SDK 本身。PHP SDK 给每次 HTTP 调用设置了 30 秒的连接上限和 120 秒的总时间上限,而且没有提供修改它们的选项。一个接受了连接却始终不应答提交的 CapSkip,让 worker 耗费了 120.0 秒,然后抛出 NetworkException。一个接下了任务、之后始终不应答结果轮询的 CapSkip,则耗费了 362.1 秒。SDK 把每次无应答的轮询都当作尚未就绪,所以三次各 120 秒的轮询已经过去之后,它才注意到 recaptchaTimeout(300 秒)已经用完,并抛出 TimeoutException。
一次在截止时间前不久变得没有应答的轮询,可能会在截止时间之后继续跑满整整 120 秒。把 recaptchaTimeout 设为 20 时,一个在 14 秒后不再应答的 CapSkip,让这次识别在 142.8 秒时才结束。这些情况每一种最终都以第 4 步的映射会重试的异常收场。它们决定了你该按多长来规划:在默认设置下,一个处理程序最多可能花大约 9 分钟,即提交 120 秒、轮询 300 秒、最后一次轮询 120 秒,再加上页面获取和提交表单的时间。
为什么 redeliver_timeout 决定了你会不会识别两次
Doctrine transport 会把消息标记为已投递,并在处理程序完成之前一直把它留在表里。如果一条消息已投递的时间超过了 redeliver_timeout,任何 worker 都可以再次取走它,不管第一个 worker 是否仍在工作。默认值是 3600 秒。
在一次测试中,redeliver_timeout 为 5 秒,一次识别耗时 15 秒,第二个 worker 在第一个 worker 之后 5.6 秒收到了同一条消息。CapSkip 识别了两次,站点接受了两次提交。测试站点的一次性 token 检查没有阻止这种情况,因为每个 worker 识别的都是它自己的 token;它只会拒绝同一个 token 被使用两次。第二个 worker 完成时,它的确认操作没有删除任何行,也没有报错。把超时设为 40 秒后,同一个测试只产生了一次识别和一次提交。
所以这个值必须比你最慢的处理程序更长。按上面的 SDK 上限,一个处理程序可能耗时大约 9 分钟,因此第 2 步里的 messenger.php 文件把它设为 900 秒。代价是,被崩溃的 worker 占着的消息,要等这么久才会被另一个 worker 取走。较低的值只是一道安全网,而不是解决办法。在一次第一个 worker 的识别挂起的测试中,20 秒的超时让第二个 worker 接手并完成了任务。挂起的那个 worker 随后在 120 秒时失败,并为自己安排了一次重试,结果这个作业被识别了第二次,站点接受了两次提交。
停止 worker,又不丢掉这次识别
Messenger 通常通过 pcntl 监听 SIGTERM 和 SIGINT,所以按 Ctrl+C 就意味着先处理完当前这条消息,再停止。Windows 上的 PHP 没有 pcntl。一次真实的 Ctrl+C 事件在 0.01 秒内就在识别进行到一半时杀掉了 worker,退出码为 0xC000013A,也没有任何日志行。消息带着已设置的投递时间留在表里,所以在默认超时下,其他 worker 要过一个小时才会看到它。
把 redeliver_timeout 设为 10 秒时,第二个 worker 在消息入队大约 22 秒后收到了它,并再次识别,第一次的识别就白费了。
解决办法是一个监听器,它在 worker 启动时安装 Windows 控制台处理程序。
<?php
// src/EventListener/WindowsGracefulStop.php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Event\WorkerStartedEvent;
#[AsEventListener]
final class WindowsGracefulStop
{
public function __invoke(WorkerStartedEvent $event): void
{
// Without pcntl, Ctrl+C kills the process at once. This turns it into "finish the message, then stop".
if (function_exists('sapi_windows_set_ctrl_handler')) {
$worker = $event->getWorker();
sapi_windows_set_ctrl_handler(static fn () => $worker->stop());
}
}
}有了它,按 Ctrl+C 会记录 Stopping worker,worker 会完成识别和提交,确认这条消息,并在大约 15 秒后以退出码 0 退出。keepalive 选项在 Windows 上没有帮助:它需要 pcntl,并会以 Signals are not supported 停止。messenger:stop-workers 命令则可以在两个进程之间正常工作。空闲的 worker 在 0.3 秒内停止,忙碌的 worker 会先处理完它手头的消息,下一条仍留在队列中。
这个监听器是有代价的。Ctrl+C 不再能中止一次卡住的识别,因为它现在的意思是处理完这条消息后停止,再按一次也不会强制退出。在一次测试中,每隔两秒按一次、共按四次,仍然等到了那次 20 秒的识别结束。如果 CapSkip 已经不再应答,这可能要等好几分钟,所以当你需要立刻让进程消失时,请从 Task Manager 里结束它。
让 worker 持续运行
worker 本来就是要时不时退出的,因为时限和内存限制选项会让它主动停止。PowerShell 里的一个循环会在每次退出后启动一个新的 worker。它通过 cmd 来运行 worker,这样日志文件就是纯文本;在 Windows PowerShell 5.1 中重定向原生命令的输出,会写成 UTF-16,还会夹带错误噪音。
while ($true) {
cmd /c "php bin/console messenger:consume async -vv --time-limit=3600 --memory-limit=128M >> var\worker.log 2>&1"
Start-Sleep -Seconds 5
}测试中,循环在因时限而退出之后、以及进程被杀掉之后,都会启动一个新的 worker。让这个循环在登录 Windows 时启动(比如通过 Task Scheduler,或作为服务)没有在这里测试过。
不要对同一个作业识别两次
重试会重复整个处理程序。如果表单提交成功,而后面的某一步抛出异常(比如保存结果时),下一次尝试就会再次识别并再次提交。在一次测试中,保存步骤总是在提交之后失败,第 5 步的处理程序为同一条消息做了 4 次识别、4 次被接受的提交。
一个小台账就能解决这个问题。提交之后,消息里的作业 id 会立刻写入一张表,而重试时一旦发现这个 id,就会跳过识别,只运行失败的那一部分。
<?php
// src/JobLedger.php (SQLite syntax: INSERT OR IGNORE)
namespace App;
use Doctrine\DBAL\Connection;
final class JobLedger
{
public function __construct(private Connection $db)
{
$this->db->executeStatement('CREATE TABLE IF NOT EXISTS job_done (job_id TEXT PRIMARY KEY, status INTEGER NOT NULL)');
}
public function status(string $jobId): ?int
{
$status = $this->db->fetchOne('SELECT status FROM job_done WHERE job_id = ?', [$jobId]);
return $status === false ? null : (int) $status;
}
public function done(string $jobId, int $status): int
{
$this->db->executeStatement('INSERT OR IGNORE INTO job_done (job_id, status) VALUES (?, ?)', [$jobId, $status]);
return $status;
}
}处理程序的第二个版本会先查台账。用这个文件替换第 5 步的文件。
<?php
// src/MessageHandler/SolveLoginHandler.php (second version: runs the login once per job)
namespace App\MessageHandler;
use App\CapSkipFailure;
use App\JobLedger;
use App\Message\SolveLogin;
use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use Dom\HTMLDocument;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final class SolveLoginHandler
{
private CapSkip $solver;
public function __construct(
private HttpClientInterface $http,
private JobLedger $ledger,
#[Autowire(env: 'CAPSKIP_HOST')] string $host,
#[Autowire(env: 'int:CAPSKIP_PORT')] int $port,
#[Autowire(env: 'CAPSKIP_API_KEY')] string $apiKey,
#[Autowire(env: 'SITE_PASSWORD')] private string $password,
) {
$this->solver = new CapSkip(['apiKey' => $apiKey, 'host' => $host, 'port' => $port]);
}
public function __invoke(SolveLogin $message): void
{
// A retry after the POST went through skips the solve and the POST and repeats only the cheap part.
$status = $this->ledger->status($message->jobId)
?? $this->ledger->done($message->jobId, $this->login($message));
// Save the result here (your own code), using $status.
}
private function login(SolveLogin $message): int
{
$url = $message->siteUrl . '/login';
$page = $this->http->request('GET', $url);
$cookie = explode(';', $page->getHeaders()['set-cookie'][0])[0];
$doc = HTMLDocument::createFromString($page->getContent(), LIBXML_NOERROR);
$csrf = $doc->querySelector('input[name=csrfmiddlewaretoken]')->getAttribute('value');
$sitekey = $doc->querySelector('.g-recaptcha')->getAttribute('data-sitekey');
try {
$token = $this->solver->recaptcha($sitekey, $url)['code'];
} catch (CapSkipError $e) {
throw CapSkipFailure::toMessenger($e);
}
$reply = $this->http->request('POST', $url, [
'headers' => ['Referer' => $url, 'Cookie' => $cookie],
'body' => ['csrfmiddlewaretoken' => $csrf, 'username' => $message->username,
'password' => $this->password, 'g-recaptcha-response' => $token],
]);
if ($reply->getStatusCode() >= 400) {
throw new \RuntimeException('login rejected: ' . $reply->getContent(false));
}
return $reply->getStatusCode();
}
}有了台账,同样总会失败的保存步骤只产生了 1 次识别和 1 次提交,重试则直接进入保存步骤。台账不能防止两个 worker 同时运行同一个作业。只有足够长的 redeliver_timeout 才能做到这一点。job_done 表中每个作业占一行,所以要时不时删掉旧的行。
在另一台机器上运行 CapSkip
worker 和识别工具不必在同一台电脑上。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后把 .env 或 .env.local 里的 CAPSKIP_HOST 设为这个地址。
如果链路要经过公网,请使用静态公网 IP,并打开 API key validation,在 CAPSKIP_API_KEY 中使用同一个密钥。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
Symfony Messenger 验证码识别的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| No transport supports Messenger DSN doctrine://default | 缺少 doctrine-bundle,尽管错误信息点名的是 doctrine-messenger | 安装 doctrine/doctrine-bundle |
| An exception occurred in the driver: could not find driver | pdo_sqlite 没有启用,或者 .env 里仍是 recipe 写入的 PostgreSQL DATABASE_URL。两种情况给出的错误文本是一样的 | 启用该扩展并替换这个 URL |
| no such table: messenger_messages | DSN 里仍带着 recipe 写入的 auto_setup=0,所以没有任何东西来创建这张表 | 删除 auto_setup=0,或者运行 messenger:setup-transports |
| unable to open database file | SQLite 路径是相对路径 | 让路径以 %kernel.project_dir% 开头 |
| The doctrine/orm package is required when the doctrine.orm config is set | recipe 写入的 doctrine.yaml 还在 | 删除它,改用第 2 步的 PHP 文件 |
| You have requested a non-existent parameter | doctrine.yaml 被删掉了,却没有 doctrine.php 来取代它 | 添加 doctrine.php 文件 |
| Invalid Messenger routing configuration: class not found | 消息类文件缺失,或者命名空间不对 | 按第 3 步创建 src/Message/SolveLogin.php |
| Unknown option found in DSN: [redeliver_timout] | 选项拼写有误;错误信息里会列出有效的选项 | 改正拼写 |
| 出现 Removing from transport after 3 retries,随后消息就没了 | 没有配置 failed transport | 按第 2 步设置 failure_transport |
| 重试次数超过了 max_retries | 用了 RecoverableMessageHandlingException | 按第 4 步抛出普通异常 |
| 按 Ctrl+C 时 worker 直接退出,没有任何日志行 | Windows 上没有 pcntl | 添加控制台处理程序监听器 |
| 同一次登录被提交了两次 | redeliver_timeout 比处理程序运行的时间更短,或者提交之后的某一步失败了 | 调高超时,并加上台账 |
| Signals are not supported | keepalive 选项需要 pcntl | 在 Windows 上不要开启该选项 |
常见问题
这在 Symfony 7.4 LTS 上能用吗?
这些代码块是在 Symfony 8.1.8 和 PHP 8.4.15 上运行的。Symfony 7.4 需要 PHP 8.2 或更高版本,而处理程序用到了 PHP 8.4 的 HTML 解析器,所以在较旧的 PHP 上,你需要换用另一个解析器。重试的设计是一样的,普通异常在两个版本上的表现也相同。只有 RecoverableMessageHandlingException 不同,第 4 步已经解释过。7.4 的行为来自阅读它的源码,而不是实际运行。
一条用完所有重试仍然失败的消息会怎样?
有了第 2 步的 failed transport,它会以队列名 failed 进入同一张表。用 messenger:failed:show 查看它,用 messenger:failed:retry 重新发送它,用 messenger:failed:remove 丢弃它。retry 命令会在你的终端里运行处理程序。没有 failed transport 时,这些命令不存在,消息会被删除。Messenger 会把最终失败记录为 critical 级别,并带有 Removing from transport after 3 retries 这一行。
可以运行多个 worker 吗?
可以。在第二个窗口里启动 messenger:consume,每个 worker 就会取走不同的消息。在测试中,两个 worker 针对同一个 SQLite 文件运行,没有出错。CapSkip 自己的上限还要叠加在此之上:它的 reCAPTCHA 识别默认同时运行 10 个任务,其余的排队等待。
这和 Laravel 队列有什么不同?
Laravel 的 queue:work 有按任务设置的超时,但它依赖 pcntl,所以在 Windows 上会被悄无声息地忽略,正如 用 Laravel 队列识别验证码的指南 所解释的那样。Messenger 在任何平台上都没有针对单个任务的超时。它的风险在于重试异常、重新投递超时,以及缺少信号处理。
简短版结论
Symfony Messenger 验证码 worker 需要的不只是一个处理程序。发送输入,而不是 token,并在处理程序内部完成识别。抛出普通异常来重试,抛出 UnrecoverableMessageHandlingException 来停止,并添加一个 failed transport,这样就不会有消息凭空消失。把 redeliver_timeout 设得高于最长的处理程序耗时(按 SDK 的上限大约是 9 分钟),并添加 Windows 控制台处理程序,让 Ctrl+C 能先完成这次识别。 Symfony Messenger 文档 介绍了这里用到的每一个选项。
- PHP 包能处理的所有验证码类型: PHP 验证码识别工具页面.
- 复选框 reCAPTCHA 如何运作、识别工具覆盖哪些内容: reCAPTCHA v2 识别页面.
一个每天处理几百次登录的队列,一个月会产生数千次识别。用在你自己的 Windows 机器上运行的 验证码识别工具 来完成这些识别,这个数字就永远不会变成按次计费的账单。
