ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Python与Twilio搭建短信通知系统:从环境配置到线上稳定运行

用Python与Twilio搭建短信通知系统:从环境配置到线上稳定运行 我第一次意识到用 Python 和 Twilio 搭一套短信通知系统有多重要是在凌晨两点被手机短信炸醒那次。机房一台服务器静默宕机监控平台没有任何异常邮件通知躺在邮箱里没人看客户第二天早上才发现数据没同步。那之后我养成了一个习惯凡是跟稳定性沾边的服务一定把短信这类强推送通知作为兜底渠道。这篇文章我想把从第一行代码到线上稳定运行的全过程完整捋一遍包括 Python 环境怎么准备、Twilio 的核心概念怎么理解、发送接口的参数怎么用、消息收进来怎么回、线上部署会踩哪些坑。如果你刚开始学 Python照着做也能跑通如果你已经写过一些脚本里面封装、重试、Webhook 的部分可以直接拿去复用。1. 短信通知系统真正解决的是那些没人盯着的深夜问题1.1 我愿意折腾这套系统的两个真实场景第一个场景就是开头提到的服务器告警。我们当时有台专门做数据同步的机器白天一切正常半夜容易因为磁盘满、进程被杀、网络抖动之类的原因挂掉。监控平台本身是有的但它只负责在屏幕上变红不会主动找到你。等第二天用户反馈异常时中间已经过去了大几个小时。这种情况下短信几乎是唯一能真正“把人叫醒”的通道。第二个场景是业务流程跑完之后的即时通知。比如用户提交了一个数据修复工单后端处理脚本执行完需要告诉运维人员结果成功还是失败。邮件当然能发但邮件没法保证对方马上看到IM 消息更即时一些可群里每天几千条信息一条告警混进去很容易被刷掉。短信的原生优势在于它直接出现在锁屏和通知栏不需要额外安装 App不需要保持在线运营商网络的送达率在可接受的延迟范围内仍然是最可靠的。1.2 短信在通知链里的不可替代性我做过多次对比。邮件的问题是时效性差服务端的队列延迟、客户端的推送延迟、垃圾箱过滤任何一个环节都能让“及时通知”变成“事后发现”。IM 的问题是它本身依赖客户端在线状态如果你把通知发到群里群成员即使全部收到也很少有人第一时间点开处理。App 推送的问题更明显用户关掉通知权限之后消息就彻底消失了你根本不知道它有没有被系统拦截。短信不一样。它是运营商网络里最基础的消息承载机制手机系统会把它当作高优先级事件处理。哪怕手机静音锁屏上也会留一条可见的未读痕迹哪怕用户没有安装任何应用只要手机有信号就能收到。所以我的判断一直是像告警、密码重置、订单支付状态这类关键通知短信不是“高级选项”而是“最后防线”。邮件和 IM 可以作为辅助记录但必须有一条异步短信通道作为兜底。2. Twilio 选型逻辑以及开工前必须搞清楚的概念2.1 为什么不自己攒短信网关在决定用 Twilio 之前我确实考虑过自己对接运营商的短信网关。实际调研之后发现自建方案的门槛比想象中高很多。首先你得找到有资质的短信服务商谈通道、谈价格、签合同通常还有月最低消费其次要从服务商那里拿协议文档、接入地址和密钥自己维护长连接或者 HTTP 接口最麻烦的是短信送达状态和重发机制的细节需要相当多的运维经验才能处理妥当。Twilio 这类云通信平台把这些事情都封装掉了。你只需要通过一个 HTTPS 接口把“发给谁、发什么内容、用哪个号码发”三个参数传过去剩下的路由、计费、状态回执、重试机制由平台处理。Twilio 在多个国家都有当地号码资源如果你需要给不同国家的用户发短信直接在控制台选购对应国家的号码就行不用分别跟每个国家的运营商谈合作。下面是自建方案和 Twilio 方案的直观对比。对比维度自己对接网关或短信猫Twilio 云 API前期准备商务谈判、合同评审、协议对接注册账号、验证手机号、选购号码技术接入各异协议文档格式繁杂一个 SDK、一个 create 调用号码资源通常只能发本地区号或者特定通道可在控制台直接选购多国号码状态回执回执数据要自己解析、清洗消息状态字段直接返回也支持回调运维负担自研监控、重试、路由规则平台提供多冗余链路重点自管业务这套对比并不是说自建就一定不行如果短信量已经很大、对成本极其敏感自建或聚合平台仍然有空间。但对大多数个人项目和中小团队来说前期快速跑通、稳定上线、按量付费才是更合理的选择。2.2 只需要搞懂四个概念就能看懂全部代码Twilio 的文档对新手不算友好一大堆英文术语容易把人劝退。实际上真正要理解的核心只有四个Account SID、Auth Token、电话号码、Message 资源。Account SID 是你的账户唯一标识相当于“账号 ID”。它通常以AC开头后面跟一长串字符。Auth Token 是账户密钥相当于“密码”调用 API 时用来确认身份。这两个东西要像保护数据库密码一样保护尤其 Auth Token绝对不能写进代码仓库。电话号码是指你从 Twilio 购买的发送号码比如一个美国号码或者英国号码格式统一为 E.164也就是带国家代码的完整号码例如15017122661。你发的每一条短信from_参数填的都是这个号码。Message 是 Twilio 里的核心资源每个消息对象有唯一的sid还有body、from_、to、status这些字段。Python SDK 的client.messages.create()就是创建一个 Message 资源之后你可以通过打印message.sid拿到唯一编号方便后续查询。2.3 费用模型和测试环境的理解短信的计费方式和想象中不太一样。它不是按“条”统一计价而是按“段Segment”计价。一段 GSM-7 编码的短信最多 160 个字符一旦内容里包含中文或者 emoji编码会切换成 UTF-16每段的可容纳字符数会降到 70 个。一条 160 个字节以上的长短信会被拆成多段费用也按段计算。这条规则直接影响了我的通知文案设计后面会说。试用账号和正式账号也有区别。试用账号通常只能往你已验证过的手机号发送或者只能在沙箱场景里测试正式账号则需要购买号码并且给账户充值才能任意发送。所以第一次测试的时候不要奇怪为什么发到别人手机上报错先确认对方手机号有没有在控制台里完成验证。3. 环境搭建从 Python 安装到 SDK 跑通3.1 安装 Python 的几个细节如果你是完全的新手这一步最容易卡住的是 PATH 问题。Windows 用户在安装 Python 时第一个界面就要勾选“Add Python to PATH”不勾的话后面在命令行敲python会直接提示找不到命令。安装完成后打开命令行窗口输入两条命令验证python --version pip --version如果python命令不可用但py命令可用说明你的机器上安装了 Python Launcher可以用py -3 --version检查版本。在 Windows 上还容易出现一种情况命令行窗口是安装 Python 之前开的必须新开一个窗口才能读取新生效的 PATH 环境变量。这一点很多人忽略总以为安装失败。Mac 和 Linux 用户一般自带 Python但版本可能偏旧。建议安装当前主流的稳定版本Python 3.10 以上都没什么问题。Twilio 的 SDK 对老版本兼容性还可以但新项目没必要把自己的起点定在旧版本上。3.2 创建虚拟环境并安装 Twilio 依赖我强烈建议每个 Python 项目单独建一个虚拟环境。不同项目的依赖版本可能会互相冲突全部装到系统全局环境里一段时间后就会变成依赖地狱。虚拟环境就是一个独立的目录里面有自己的 Python 解释器和第三方包。mkdir sms-notification cd sms-notification python -m venv .venv激活方式根据操作系统不同有一点差异。Windows 命令行里运行.venv\Scripts\activateLinux 或者 macOS 的终端里运行source .venv/bin/activate激活成功后命令行前缀会变成(.venv)。然后安装 Twilio SDK 和读取环境变量的工具pip install twilio python-dotenv这里顺便解释一下为什么装两个包。twilio是官方的 REST API SDK发送短信的所有逻辑都靠它python-dotenv用来读取.env文件里的配置把敏感信息从代码中剥离出去。Twilio SDK 内部封装了签名验证、重试和请求序列化不需要自己手动写 HTTP 请求。3.3 在 Twilio 控制台索取账号参数注册 Twilio 账号之后控制台首页就能看到 Account SID 和 Auth Token。这两个值是全局信息进入控制台默认展示点击即可复制。接着在控制台里购买一个支持短信的号码。号码有月租费用按天计费如果只是测试用完建议释放掉避免一直扣费。购买号码之后把手机号加入 Verified Caller IDs。这一步的本质是验证“你是谁”也是试用账号发短信的前提条件。你在控制台填入自己的真实手机号Twilio 会给这个号码发送一个验证码输入验证码即完成验证。这一步做完环境准备就结束了接下来可以开始写代码。4. 发送第一条短信代码级别的完整拆解4.1 最轻量的发送代码长什么样在项目目录下创建一个.env文件把刚才拿到的参数都放进去TWILIO_ACCOUNT_SIDACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKENxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_PHONE_NUMBER15017122661 NOTIFY_PHONE_NUMBER8613800138000注意这里的NOTIFY_PHONE_NUMBER是收信人的号码格式必须是 E.164也就是带国家代码、带加号的完整格式。中国大陆手机号写成8613800138000不要写成13800138000也不要写成86 13800138000。然后新建send_sms.pyimport os from dotenv import load_dotenv from twilio.rest import Client load_dotenv() account_sid os.getenv(TWILIO_ACCOUNT_SID) auth_token os.getenv(TWILIO_AUTH_TOKEN) from_number os.getenv(TWILIO_PHONE_NUMBER) to_number os.getenv(NOTIFY_PHONE_NUMBER) client Client(account_sid, auth_token) message client.messages.create( body你好这是来自 Python 的短信通知。, from_from_number, toto_number, ) print(message.sid) print(message.status)运行python send_sms.py如果手机在几十秒内收到了短信说明第一个里程碑跑通了。4.2 messages.create 的每个重要参数很多人第一次写这段代码会困惑为什么发送方的参数叫from_而不是from。因为from是 Python 的关键字不能用做参数名所以 SDK 在名字后面加了一个下划线。这个细节很容易被初学者当成拼写错误其实是有意为之。body是短信正文长度限制刚才已经提过。中文短信每段 70 个字符超过 70 个字符会被拆成两段发送。这里的“字符”包括标点和空格。如果你的通知内容比较复杂尽量精简表达控制在 70 字以内既省钱又能避免多段拼接带来的阅读混乱。from_是你购买的 Twilio 号码必须用 E.164 格式。to是收信人号码同样要求 E.164。格式一旦有问题Twilio 会返回“号码无效”之类的错误下面的异常捕获部分会展开讲。除了这三个参数还有两个常用的可选参数。status_callback参数可以填一个 URLTwilio 在消息状态变化时会向这个 URL 发起回调media_url参数可以传图片地址用于发送彩信。大多数自动通知场景用不到彩信但如果你做的是带截图的运维告警把故障页面截图通过media_url一起发出来价值会高很多。4.3 发送之后的返回结果怎么看message.sid是这条消息的唯一标识后面的排查和回执查询都靠它。message.status是发送状态常见取值有queued、sending、sent、delivered、failed。第一次发送时status打印出来通常还是queued这并不代表失败只是说明消息已经进入 Twilio 的发送队列后面会异步更新。如果需要精确的送达结果最靠谱的方式是配置状态回调。在控制台或者代码里指定status_callbackTwilio 会在消息状态变成delivered或failed时通知你。订阅接口和 Webhook 回调逻辑我会在第六节展开。这里想强调一点发送成功不代表送达成功queued到sent只说明 Twilio 把消息交给了运营商只有回调里出现delivered才能确认对方手机真正收到。正式项目里不要把message.status打印出来就完事应该把sid、status、时间一起写进日志后续出现投诉或丢消息时才有据可查。我在排查“为什么用户说没收到短信”时第一步永远是查这条消息在 Twilio 后台的最终状态而不是猜运营商是不是吞了。5. 把单条发送包装成可以长跑的通知服务5.1 用函数封装隐藏平台差异第一步的代码只能算“跑通”离“能用”还很远。直接在你自己的业务代码里到处写client.messages.create会让以后更换短信服务商的成本变高。更好的做法是封装一个统一的短信发送函数业务代码只调用这个函数具体走 Twilio 还是别的通道控制在封装内部。import os from twilio.rest import Client _client None def get_client(): global _client if _client is None: _client Client( os.getenv(TWILIO_ACCOUNT_SID), os.getenv(TWILIO_AUTH_TOKEN), ) return _client def send_sms(to_number, content): message get_client().messages.create( bodycontent, from_os.getenv(TWILIO_PHONE_NUMBER), toto_number, ) return message.sid这样做的另外一个好处是方便做单测。测试环境里可以把这个函数 mock 掉完全不需要真的调用 Twilio 接口。把平台差异隔离在一个模块内部是短信通知服务最基础也最重要的设计。如果一次要给多个人发建议在循环里加一点间隔。原因是运营商对同一号码在短时间内的发送频率有风控连续发几十条很容易触发限制。我自己的习惯是每次循环间隔 1 到 2 秒不是技术限制而是给自己留点余地避免被当成群发营销。5.2 失败重试和指数退避短信通知跑在公网上网络抖动、服务商临时故障、余额不足都是可能出现的异常。Twilio SDK 自带一部分 HTTP 层的重试机制但对业务层的发送失败还是建议自己补充一层重试逻辑。一个简单的指数退避重试函数import time from twilio.base.exceptions import TwilioRestException def send_with_retry(client, payload, retries3): for attempt in range(1, retries 1): try: message client.messages.create(**payload) return message.sid except TwilioRestException as exc: print(f发送失败HTTP {exc.status}Code {exc.code}{exc.msg}) if attempt retries: raise wait 2 ** attempt print(f{wait} 秒后进行第 {attempt 1} 次重试) time.sleep(wait)需要强调的是并不是所有错误都值得重试。像账号认证失败、号码格式错误这类问题重试多少次都不会成功反而会把日志刷得很难看。正确的处理方式是区分错误码网络超时和平台 5xx 错误可以重试20003这类认证失败、号码校验失败就直接抛异常让上层处理。5.3 挂上定时任务让系统主动找人单条发送函数封装好后真正让通知系统运转起来的是定时触发。常见做法是用操作系统的 cronLinux或者计划任务Windows定期运行检查脚本。比如每 10 分钟检查一次磁盘使用率*/10 * * * * /path/to/.venv/bin/python /path/to/check_disk.py /var/log/sms_disk.log 21这里有两个细节容易被坑。第一cron 里的 Python 要写绝对路径不要写python否则可能意外调用了系统自带的旧版本解释器。第二cron 的环境变量非常少.env文件必须能被脚本找到最简单的办法是在脚本开头用绝对路径定位.env。我见过不少定时任务跑了一晚上结果一条短信都没发出去最后发现是工作目录不对.env根本没被加载。对 Python 本身比较熟悉的开发者也可以直接用schedule或者APScheduler库在进程内维护定时任务。但我仍然推荐 cron 作为主力因为 cron 出问题时会非常显眼地体现在日志缺失上而进程内调度一旦挂掉往往连日志都看不出来。6. 短信进来了怎么办用 Webhook 做双向通知6.1 Webhook 机制先理解透很多人以为 Twilio 的 Webhook 是一个需要主动调用才能获取消息的接口实际上相反当别人回复了你的 Twilio 号码Twilio 会把这条短信的内容 POST 到你预先配置好的 URL 上。你的服务器收到请求后再返回一段 TwiML 指令Twilio 根据这段指令决定要不要自动回复。理解透这个机制会避免一个常见的误区不要在 TwiML 返回里调用client.messages.create去“发”回复。Twilio 本身就是根据你的 TwiML 来发送回复的你只需要返回一个Message节点。如果你又在代码里手动调了一次发送对方会收到两条同样的消息。6.2 用 Flask 接收短信并自动回复先给项目安装 Flaskpip install flask然后写一个最简单的 Webhook 入口from flask import Flask, request from twilio.twiml.messaging_response import MessagingResponse app Flask(__name__) app.route(/sms, methods[GET, POST]) def sms_reply(): body request.form.get(Body, ) from_number request.form.get(From, ) print(f收到来自 {from_number} 的短信{body}) resp MessagingResponse() resp.message(收到你的消息啦我会尽快处理。) return str(resp) if __name__ __main__: app.run(host0.0.0.0, port8000)Twilio 向这个 URL 发起 POST 请求时携带的参数除了Body和From还有MessageSid、To、NumMedia等字段。在这些字段里Body是短信正文From是回复者的手机号码MessageSid是回执消息的唯一编号。把它们记录到日志中可以还原一次完整的会话。6.3 TwiML 和关键词回复的一个小例子TwiML 可以做的事比想象中多。除了简单的文本回复你可以根据关键词走不同分支。比如做一个简单的值班系统用户回复“值班”就直接告诉他当班同事是谁回复“状态”就返回最近一次系统检查结果。app.route(/sms, methods[POST]) def sms_reply(): body request.form.get(Body, ).strip() resp MessagingResponse() if body 值班: resp.message(今天值班张三电话 138********。) elif body 状态: resp.message(最后一次检查正常。) else: resp.message(无法识别指令请回复“值班”或“状态”。) return str(resp)需要注意的是TwiML 的返回必须一次性生成并返回给 TwilioTwilio 收到后可做的指令非常有限。如果你想实现“用户回复一段话之后再调用外部业务接口查询结果再回复”必须在同一段 TwiML 里完成不能先返回一段话再异步发第二条。虽然 Twilio 也支持后续主动消息但这条消息必须由你的服务器另外调用发送接口和 TwiML 不是一回事。上线 Webhook 之前还有一步不能省在 Twilio 控制台把回调 URL 配成https://你的域名/sms并且要求 Twilio 做请求签名校验。SDK 提供了twilio.request_validator用来验证请求头里的X-Twilio-Signature是否合法。不校验证签名就暴露出一个任何人都能伪造请求的接口你的小服务很容易变成免费短信入口。7. 上线半年踩过的典型坑排查过程记录7.1 认证失败的排查链路项目上线第一周我就遇到过发送接口一直报认证失败。错误信息里最常见的是HTTP 401Twilio 侧的说明大致指向20003 Authentication Failed。遇到这个现象不要急着怀疑 Twilio 账号出了问题按下面的顺序检查。先看.env文件里的TWILIO_AUTH_TOKEN和TWILIO_ACCOUNT_SID是否有拼写错误或者多了空格。再看代码里load_dotenv()是否在读取环境变量之前被调用很多脚本把load_dotenv()写在了文件末尾导致前面读取到的都是None。最后检查 Auth Token 是否含有#、:这类特殊字符某些老版本 SDK 或代理工具会对这类字符做意外解析建议直接在控制台轮换一个新 Token 试试。解决之后顺手把密钥改用 Secrets Manager 之类的服务管理而不是继续依赖.env。7.2 号码无效和未验证的情况这类问题常表现为HTTP 400Twilio 的错误码大概是21211或者21610附近。21211常见于to号码格式非法比如中国国内手机号只写了13800138000没有加86或者收信人号码里带了空格、括号、短横线这些视觉符号。E.164 格式只允许数字和开头的这是硬性规则。另一个容易忽略的场景是试用账号的验证限制。如果你仍然处于试用模式收信人号码必须在控制台的 Verified Caller IDs 里完成验证否则接口会拒绝发送。很多人上线前忘了切到正式账号到了生产环境还带着试用的号码白名单最终表现为“测试能发、上线不能发”。这种情况不需要改代码去控制台升级账号或者把号码验证配置同步过来就能解决。7.3 消息显示已发送却收不到比接口报错更棘手的是“没报错但手机就是没收到”。排查路径应该是先把message.sid拿到 Twilio 控制台的 Message Log 里查询看最终状态是什么。如果状态是failed说明 Twilio 已经认为这条消息失败了具体原因会显示在错误详情里如果状态是sent说明 Twilio 把消息交给了运营商后续可能需要运营商侧进一步确认。这个环节里我发现过两种情况。第一种是短信内容触发了目标运营商的拦截规则尤其当内容包含链接、验证码、营销词的时候被拦截概率会明显升高。第二种是发送频率太高同一号码短时间内收到多条短信被运营商的风控策略静默丢弃。控制台里消息状态是delivered但用户确实没收到这种情况通常只能在特定运营商环境下复现。减少发送频率和严格控制文案类型是成本最低的降风险方式。7.4 本地回调地址配置时的误区开发阶段最让人迷惑的就是 Webhook 回调不通。你本地跑着 Flask 服务把回调地址填成http://127.0.0.1:8000/smsTwilio 当然访问不到因为 127.0.0.1 是你自己的回环地址。控制台里的回调 URL 必须是 Twilio 服务器能访问的公网地址。本地调试的可行方案是用内网穿透工具把本地端口暴露到一个临时公网域名然后把该域名配置成回调地址。注意这类工具生成的域名是随机变化的每次重启都要重新配置一次而且回调和你的本地服务之间是明文传输调试完一定要记得移除或者加签名校验。一旦进入正式环境回调地址必须走 HTTPS并且建议放在 CDN 或者网关后面不要把原始服务器直接暴露在公网。8. 关于安全、成本与长线运行的几个习惯8.1 凭据保护是红线整个 Twilio 项目里Auth Token 就是你的钱袋子。别人拿到这个 Token可以利用你的号码发送短信到任何地方产生的费用全部记在你账上。GitHub 上专门有扫描工具在监控公开仓库里的密钥只要仓库里有一句TWILIO_AUTH_TOKENAC...几分钟内就会被人尝试盗用。所以.env从一开始就必须写进.gitignore任何情况下都不要提交到版本库。如果发现密钥已经暴露最快的补救办法是在控制台直接轮换 Auth Token。轮换后旧 Token 立刻失效问题就能被掐断在源头。另外还要定期检查 Message Log 里是否有你没见过的发送记录。这个习惯虽然简单但能让你在异常发生后的几分钟内发现异常而不是月底看到账单才傻眼。提示千万不要把真实号码和真实 Token 打印在测试代码里分享到群里或者博客里。即使只是截图也可能被 OCR 工具识别。正式演练时用虚拟号码或者临时 Token跑通之后再换回真实信息。8.2 成本控制和内容风控Twilio 按段收费中文短信每段短、价格有上限但对高频通知仍然是一笔不小开销。我自己的控制方法是三类通知分优先级最高优先级是服务不可用告警必须实时逐条发送中等优先级是业务订单状态变化可以合并在一个时间窗口内批量发送低优先级是每日统计报表改用定时推送一条汇总。合并低优先通知的好处是减少消息段数也减少对用户的打扰。内容风控同样重要。短信通道最怕被运营商认定为营销或者骚扰号码。一旦号码信誉降低送达率会断崖式下降。建议正文不要使用夸张促销语不要附带短链接除非业务确实需要。如果发送给终端用户的通知属于订阅类型必须在文案中提供退订方式。Twilio 官方对STOP这类退订关键词有自动处理机制用户回复STOP之后系统会自动标记该号码拒收营销信息这是平台层面的合规基础业务代码不需要额外实现但你应该了解它的存在。8.3 我上线前检查的清单最后放一份我现在每次新项目接入短信通知都会过的检查清单按顺序执行可以减少绝大多数上线事故。确认.env是否在.gitignore中仓库里没有真实 Token。确认代码里load_dotenv()在首次读取环境变量之前执行。确认所有手机号都使用 E.164 格式测试用例里同时覆盖国内和国外号码。确认发送函数封装独立模块业务代码不直接依赖 Twilio。确认异常捕获区分可重试错误和不可重试错误。确认关键的告警消息同时有日志记录和sid记录。确认 Webhook 回调地址启用了签名校验。确认控制台消息记录有定期人工巡检或者自动化告警订阅。确认低优先级通知做了合并发送。确认正式账号已经切出试用模式。照着这份清单走一次短信通知系统基本可以放心跑几个月。真正稳定运转之后你通常会忘记它的存在——这恰恰说明它该在的时候都在不该响的时候也没有乱响。这就是通知系统最好的状态。
返回列表