项目中第一次对接短信的时候,直接把阿里云SDK的调用写在了业务代码里。后来市场部说要加一条腾讯云的通道做备份,结果改得鸡飞狗跳——所有跟短信有关的地方都得判断当前用哪家服务商,代码瞬间膨胀了好几倍。那之后我专门用ThinkPHP 8的服务容器和门面重构了短信模块,把通道切换变成改一行配置的事。这篇文章就把整个设计思路和代码完整还原出来,顺便聊聊服务容器在实际业务中的用法。
把“发送短信”抽象成一个契约
第一步是先定义“短信发送”这件事应该长什么样,而不关心具体由谁来发。这其实就是面向接口编程。在ThinkPHP 8里,我习惯把这类抽象接口放在一个叫contract的目录下:
// app/common/contract/SmsSender.php
namespace appcommoncontract;
interface SmsSender
{
public function send(string $phone, string $content): bool;
public function getBalance(): int;
}
两个方法:发短信和查余额。任何短信服务商只要实现了这个接口,就能无缝接入系统。接下来所有的业务代码只依赖这个接口,永远不会直接去new阿里云或腾讯云的类。
实现两个真实的短信驱动
契约定好之后,把阿里云和腾讯云的SDK各自包一层,让它们符合契约。下面只放出关键结构,具体SDK调用细节不是重点。
阿里云短信驱动:
// app/common/driver/sms/AliyunDriver.php
namespace appcommondriversms;
use appcommoncontractSmsSender;
class AliyunDriver implements SmsSender
{
protected $config;
public function __construct(array $config)
{
$this->config = $config;
}
public function send(string $phone, string $content): bool
{
// 实际调用阿里云SDK,这里用伪代码表示
// $client = new AlibabaCloud($this->config['access_key'], $this->config['secret']);
// return $client->sendSms($phone, $content);
return true;
}
public function getBalance(): int
{
// 查询阿里云余额
return 100;
}
}
腾讯云短信驱动:
// app/common/driver/sms/TencentDriver.php
namespace appcommondriversms;
use appcommoncontractSmsSender;
class TencentDriver implements SmsSender
{
protected $config;
public function __construct(array $config)
{
$this->config = $config;
}
public function send(string $phone, string $content): bool
{
// 调用腾讯云SDK
return true;
}
public function getBalance(): int
{
return 200;
}
}
在服务容器里绑定契约与实现
有了接口和实现,接下来的关键一步是告诉容器,当别人需要SmsSender接口时,应该给哪一个驱动。ThinkPHP 8提供了灵活的服务注册方式,我选择在一个服务提供者里完成绑定:
// app/common/provider/SmsServiceProvider.php
namespace appcommonprovider;
use thinkService;
use appcommoncontractSmsSender;
use appcommondriversmsAliyunDriver;
use appcommondriversmsTencentDriver;
class SmsServiceProvider extends Service
{
public function register()
{
// 从配置读取当前使用的驱动名称
$driverName = config('sms.default'); // 如 'aliyun' 或 'tencent'
$this->app->bind(SmsSender::class, function ($app) use ($driverName) {
$config = config("sms.drivers.{$driverName}");
return match ($driverName) {
'aliyun' => new AliyunDriver($config),
'tencent' => new TencentDriver($config),
default => throw new InvalidArgumentException("不支持的短信驱动: {$driverName}"),
};
});
}
public function boot()
{
// 启动时加载配置,这里可以预留
}
}
这个服务提供者会在系统启动时执行register方法,把SmsSender::class这个抽象绑定到一个闭包上。闭包里根据配置动态创建驱动实例。以后任何地方要获得短信服务,只需要依赖注入或者从容器中取SmsSender::class,拿到的一定是当前配置里指定的那个驱动。
别忘了在app/service.php里注册这个服务提供者:
return [
appcommonproviderSmsServiceProvider::class,
];
短信的配置文件config/sms.php大致结构:
return [
'default' => env('sms.default', 'aliyun'),
'drivers' => [
'aliyun' => [
'access_key' => env('ALIYUN_ACCESS_KEY', ''),
'secret' => env('ALIYUN_SECRET', ''),
],
'tencent' => [
'secret_id' => env('TENCENT_SECRET_ID', ''),
'secret_key' => env('TENCENT_SECRET_KEY', ''),
],
],
];
用门面让调用更简洁
现在业务代码里要发短信,可以使用依赖注入,例如在控制器里:
use appcommoncontractSmsSender;
class RegisterController
{
public function sendCode(SmsSender $sms)
{
$sms->send('13800138000', '您的验证码是1234');
}
}
这种方式已经很干净了,但有些旧代码或者模板里不容易用依赖注入的地方,更习惯用静态调用。ThinkPHP 8的门面机制可以帮上忙。我创建一个短信门面:
// app/common/facade/Sms.php
namespace appcommonfacade;
use thinkFacade;
/**
* @method static bool send(string $phone, string $content)
* @method static int getBalance()
*/
class Sms extends Facade
{
protected static function getFacadeClass()
{
return appcommoncontractSmsSender::class;
}
}
门面背后还是从容器里解析SmsSender::class,所以它拿到的实例和依赖注入是完全同一个。现在任何地方都可以这样写:
use appcommonfacadeSms;
class OrderController
{
public function notify()
{
$result = Sms::send('13900010001', '您的订单已发货');
$balance = Sms::getBalance();
}
}
看起来像调用了静态方法,实际上每次调用都会从容器中取出已经绑定好的驱动实例。这样做既保留了调用的便利性,又完全不影响底层的解耦和可测试性——在单元测试里可以随时用app()->bind(SmsSender::class, $mock)替换为假对象。
切换通道只改一行环境变量
现在如果想把短信从阿里云切换到腾讯云,只需要在.env文件里修改:
SMS_DEFAULT=tencent
或者在config/sms.php里直接改'default'的值。不需要动任何业务代码,连缓存都不用清理,因为驱动选择是在每次请求时通过绑定的闭包动态决定的。开发环境和生产环境、主通道和备用通道,随时可以通过配置切换。
更进一步的用法是运行时切换:假设主通道发送连续失败三次,可以临时把容器里的SmsSender绑定换成另一个实现。具体的故障切换逻辑写在中间件或者事件里即可,核心是容器提供了足够的灵活性。
实际落地时补充的几个点
- 发送日志统一记录。在
SmsServiceProvider的register里,我用装饰器模式包裹了真实驱动,在send方法前后加上日志写入和异常捕获,这样所有驱动的日志格式一致,不用每个驱动自己写。 - 门面代码提示。门面类上面的
@method注解能让IDE正确识别静态调用的方法,写起来有完整的自动补全。 - 多通道并发。如果业务需要同时给用户发短信并备用另一个通道做通知,可以给不同用途绑定不同的驱动名称,比如
register('sms.marketing', AliyunDriver::class)和register('sms.system', TencentDriver::class),容器完全支持。
这次重构给我的最大感受
ThinkPHP 8的服务容器和门面并不是什么新概念,早在Laravel时代就被反复讨论。但真正在项目里扎扎实实把“面向接口编程”和“容器依赖管理”用起来之后,才体会到它的价值。以前修改短信通道要全局搜索替换,现在改一行配置就能上线,风险骤降。更重要的是,新同事接手上手时只需要看契约接口就知道怎么用,完全不用关心底层是阿里还是腾讯。
对于中小团队来说,这种程度的架构既不重,又能明显提升可维护性。如果你也在用ThinkPHP 8做项目,不妨试着把那些可能变化的第三方依赖用同样的方式封装起来,改配置比改代码踏实得多。

