ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java生成验证码:从字符画原理到头歌实验通关指南

Java生成验证码:从字符画原理到头歌实验通关指南 头歌平台上“Java生成验证码”这道实验题最近问的人特别多。很多同学上来就被“验证码”三个字吓住了以为非要画出一张和网站登录页一样花里胡哨的图片结果卡在Java绘图那一块要么类名写错要么输出格式多了个空格在评测页面反复挂掉。我帮人调试这类题目也有不少次了可以负责任地说这道题其实考得非常基础随机数、字符串拼接、循环、二维数组、格式化输出全是Java刚入门阶段的东西难点只在于你知不知道题面到底要什么输出以及头歌的在线评测到底怎么判你的代码。这篇内容先把验证码生成的原理讲明白再给出两套可以直接过实验的代码最后聊聊真实项目里验证码校验和防刷的设计思路。适合正在做头歌实验、准备Java基础面试或者想用Java写一个验证码组件的人。1. 这道实验题到底在考什么1.1 验证码的用途与常见形态先明确一个概念验证码并不是“必须长成图片”才是验证码。从广义上讲只要是一段让程序生成、再让人来识别或输入的随机信息都可以叫验证码。最常见的是四位或六位随机数字在短信登录接口里你会收到“您的验证码是123456”稍微复杂一点的是字母数字混合字符串比如很多网站注册页面的图片验证码一般会排除容易看错的字符再往上是算术验证码35、滑块拼图、点选文字以及现在很多App默认不弹出来的无感风控验证。我之所以先讲这个是因为头歌实验里“生成验证码”这个题面完全可以对应上面任何一种形态。但平台毕竟是教学场景绝大多数情况下它不会让你接一个验证码识别服务也不会让你调第三方SDK它只是叫你用纯Java代码把一个“验证码”生成出来并按照规定的格式输出。把题面理解偏了就容易写出复杂但不对题的代码这在初学者里太常见了。验证码存在的意义不是阻挡人而是阻挡机器。对普通用户来说一次验证码输入只是多花几秒钟但对自动化脚本来说验证码是一道成本栅栏能挡住批量注册、撞库、短信轰炸这类操作。所以验证码的设计一直围绕两件事展开一是让人容易识别二是让机器难以自动识别。实验题里你只需要关心第一件事的生成部分但理解“为什么要这样设计”对以后做真实项目更重要。1.2 头歌实验的评测机制与两个隐藏难点头歌是一个非常典型的在线实践教学平台它的评测方式是“提交代码-自动编译-运行-比对输出”。也就是说你的程序在本地运行得再好只要输出格式和评测期望不完全一致就会被判错。这里有两个隐藏难点一个是类名很多实验题把启动类固定成Main你写public class Captcha都可以在本地跑但提交上去就编译失败另一个是输出中的空白字符System.out.print和System.out.println差了一个换行多了一个尾随空格肉眼看不出来评测系统一比就崩。所以我的建议是拿到题面先别急着写代码花两分钟确认三件事。第一启动类名必须是哪个第二需要从标准输入读数据还是直接运行输出第三输出结果是一行字符串还是多行字符图案。把这三件事搞清楚了这道题其实就完成了一半。很多同学卡住不是不会写代码而是根本没有仔细读题直接在旧代码上改来改去越改越乱。这道题换一种包装其实就是面试机试题。我见过不少公司会让候选人现场写一个“生成6位短信验证码”的工具类考察点几乎一样随机数、字符串拼接、边界条件、代码规范。所以别觉得头歌实验只是应付学分这个知识点是真实会考的。2. 验证码生成的核心原理拆开揉碎讲2.1 字符集设计与随机数选择生成验证码的第一步是确定从哪些字符里选。这里有个我在真实项目里踩过坑的经验字符集合不能闭着眼睛把所有的A-Z、a-z、0-9都放进去。像0和O、1和l、I这几个字符在大部分字体下长得几乎一样用户输错一次就要重新输入体验很差。所以成熟的做法是从源头剔除这些易混淆字符只留“看起来不会认错”的那部分。我常用的字符池是String pool ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789;剔除了0、O、1、l、I剩下的字符既有大小写也有数字足够应付绝大多数场景。有些真实项目连大小写都不混合因为用户经常分不清大写R和小写r干脆只用大写字母加数字这也不失为一种体验优先的方案。随机数的选择也值得说一下。很多人习惯用Math.random()它也能用但每次用都要做一次double到int的换算我更推荐用java.util.concurrent.ThreadLocalRandom它不需要创建对象线程安全语义也清晰。取出随机下标的方法是这样int index ThreadLocalRandom.current().nextInt(pool.length()); char c pool.charAt(index);这句话的意思很好理解在字符池的长度范围内取一个随机整数作为下标再从字符串里取出那个位置的字符。你可以把它想象成在一副牌里随机抽一张牌面就是那堆字符。写代码的时候一个常见的坏习惯是把new Random()写在循环里面每循环一次就重新创建一个Random对象不仅浪费时间还容易出现随机种子太接近导致前后结果相似的问题。正确做法是复用同一个随机源。2.2 验证码长度、组合空间与循环生成验证码长度不是拍脑袋定的。拿短信验证码举例为什么大部分平台都用6位数字因为6位数字有10的6次方也就是100万种组合理论上可以暴力猜但只要有“连续输错5次就锁定”的限制100万种组合就足够安全了。而且6位纯数字在手机上输入非常方便用户不需要切换键盘。图片验证码反而常用4位混合字符原因是我的字符池去掉易混淆字符后还有52个字符4位就是52的4次方约731万种组合比6位数字的组合空间更大。在同样不允许无限重试的前提下这个空间已经能挡住绝大多数脚本。从这个角度你再去看实验题如果题面没有指定长度默认用4或5都是合理的不要觉得数字越小越容易过评测不关心这个。单个字符能随机出来了剩下的就是用循环把它拼成指定长度的验证码。这里我强烈建议用StringBuilder而不是String直接加。原因很简单String是不可变对象每次都会创建一个新字符串在验证码这种只有四五个字符的场景下性能差异忽略不计但你养成了坏习惯以后处理日志拼接、消息构建时会吃亏。代码写起来也很清爽public static String generate(int length) { StringBuilder sb new StringBuilder(length); for (int i 0; i length; i) { int index ThreadLocalRandom.current().nextInt(POOL.length()); sb.append(POOL.charAt(index)); } return sb.toString(); }这里推荐在new StringBuilder的时候传入初始容量length虽然不传也能自动扩容但传了可以减少扩容次数算是顺手的一个小优化。面试时如果能说出这个细节比背什么Java八股文可扎实多了。2.3 字符画验证码的二维数组思维头歌实验里还有一类变体要求不仅生成验证码还要用“字符画”方式把验证码打印出来比如用0和1组成矩阵来显示每个字母。这类题的核心是二维数组/字模思路和前面生成字符串完全不同。常见做法是把每个字符预先定义成一个5行×5列的点阵模板0代表空白1代表笔画。以字母A为例static final String[] FONT_A { 01110, 10001, 11111, 10001, 10001 };你一眼就能看出这个模板长什么样第一行是“01110”相当于A的顶部弧线第二行“10001”是左右两竖第三行“11111”是中间的横杠第四五行继续两竖。看起来确实就是一个字母A。如果要输出一串字符比如同时显示3个A你不能先把第一个A整体打印完再去打印第二个那样会变成纵向排列。正确思路是把这几个字的模板按行拼起来先取每个字模的第0行拼成一行输出再取第1行拼成下一行以此类推。外层循环控制“行”内层循环控制“第几个字符”最后println输出。这个“按行遍历字模再拼接”的思路比按字符遍历更符合视觉输出的逻辑也是很多入门同学一下子想不明白的地方。3. 手把手实现三套能直接运行的代码3.1 第一套控制台随机验证码字符串如果你拿到的题面只是要求“生成长度为n的随机验证码并输出”那下面这段代码可以直接用。注意把类名改成题面要求的名字头歌一般默认是Main。import java.util.concurrent.ThreadLocalRandom; public class Main { private static final String POOL ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789; public static void main(String[] args) { int length 4; // 如果实验要求从标准输入读取长度就取消下面几行的注释 // Scanner sc new Scanner(System.in); // int length sc.nextInt(); System.out.println(generate(length)); } public static String generate(int length) { StringBuilder sb new StringBuilder(length); for (int i 0; i length; i) { sb.append(POOL.charAt( ThreadLocalRandom.current().nextInt(POOL.length()) )); } return sb.toString(); } }代码本身没什么好说的关键是你提交前要确认三点题面是固定长度还是需要从键盘读长度输出要不要换行字符池是否要求必须包含数字这三点的答案往往决定了你的代码能不能一次通过。另外注意如果头歌题面没有给Scanner就不要自己写Scanner否则程序会停在等待输入的地方被判定超时。3.2 第二套用字符矩阵打印验证码图案如果你的题面要求输出“字符画”那么上面那套代码就不够用了。下面是一个完整的数字字模实现随机生成4位数字每行按拼接方式输出最终在控制台看到一个整齐的字符画验证码。import java.util.concurrent.ThreadLocalRandom; public class Main { static final String[][] FONTS { {01110, 10001, 10001, 10001, 01110}, // 0 {00100, 01100, 00100, 00100, 01110}, // 1 {11110, 00001, 01110, 10000, 11111}, // 2 {11110, 00001, 01110, 00001, 11110}, // 3 {10001, 10001, 11111, 00001, 00001}, // 4 {11111, 10000, 11110, 00001, 11110}, // 5 {01110, 10000, 11110, 10001, 01110}, // 6 {11111, 00001, 00010, 00100, 01000}, // 7 {01110, 10001, 01110, 10001, 01110}, // 8 {01110, 10001, 01111, 00001, 01110} // 9 }; public static void main(String[] args) { int[] index new int[4]; for (int i 0; i index.length; i) { index[i] ThreadLocalRandom.current().nextInt(10); } for (int row 0; row 5; row) { StringBuilder line new StringBuilder(); for (int i 0; i index.length; i) { line.append(FONTS[index[i]][row]).append( ); } System.out.println(line.toString()); } } }这里的核心就是双重循环。外层row控制5行内层遍历4个随机数字把每个数字字模的当前行拼到一起。行与行之间用两个空格分隔保证视觉上不会挤成一团。如果你要支持字母只需要继续往FONTS数组里添加字模并且把随机范围从10改成字符池大小。有一点要特别提醒所有字模必须行数一致我这里统一是5行如果你自己加字母一定要保证每个字符串数组都有5个元素不够的用“00000”补齐否则数组越界或者图案错位。这个实验最烦人的错误就出在这里。3.3 第三套Java图形验证码真实项目形态如果你学习的目标是“验证码是怎么画成图片的”下面这套更接近真实项目。Java自带的BufferedImage加Graphics2D几十行就能画出一张带干扰线的图片验证码import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.util.Random; public class ImageCaptcha { public static void main(String[] args) throws Exception { int width 130; int height 45; BufferedImage image new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g image.createGraphics(); Random r new Random(); g.setColor(Color.WHITE); g.fillRect(0, 0, width, height); // 干扰线 for (int i 0; i 6; i) { g.setColor(new Color(r.nextInt(200), r.nextInt(200), r.nextInt(200))); g.drawLine(r.nextInt(width), r.nextInt(height), r.nextInt(width), r.nextInt(height)); } // 绘制验证码字符 String code AB3D; g.setFont(new Font(SansSerif, Font.BOLD, 28)); for (int i 0; i code.length(); i) { g.setColor(new Color(r.nextInt(100), r.nextInt(100), r.nextInt(100))); g.drawString(String.valueOf(code.charAt(i)), 12 i * 28, 32); } g.dispose(); ImageIO.write(image, png, new File(captcha.png)); System.out.println(验证码图片已生成 code); } }这里有几个参数可以自己调图片宽高、字体大小、字符起始位置。像12 i * 28就是让四个字符从左往右每隔28像素画一个图片宽度130最后一个字符在123*2896的位置加上字符本身宽度刚好有留白不会被截断。真实项目里你通常不会把图片落盘而是转成Base64字符串返回给前端ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(image, png, baos); byte[] bytes baos.toByteArray(); String base64 Base64.getEncoder().encodeToString(bytes); System.out.println(data:image/png;base64, base64);Java 8开始自带的java.util.Base64就能做不需要额外依赖。前端拿到这个data URL直接放进img标签的src里就能显示。这也是前后端分离项目里最常见的验证码图片传递方式。3.4 提交前的三个致命细节不管用哪一套代码头歌平台提交时都有三个特别容易踩的坑我在这里集中说一次。第一个是类名。如果题面要求public class Main你写了别的名字本地能跑提交必挂。解决方法是先看实验说明或者看代码模板里给出的类名。第二个是不要写package语句。头歌的在线编译器直接编译你粘贴的代码带package会立刻报错。import语句没问题但package绝对不行。第三个是输出格式。先确认题面是输出一行还是多行要输出几个字符有没有要求全部大写。最稳的做法是在本地先跑一次把输出粘贴到记事本里开启“显示所有字符”功能检查一下有没有尾随空格。这个细节非常阴因为在线评测通常是拿你的输出和标准输出做逐字符对比差一个空格都是错。4. 常见问题与排查实录4.1 编译和运行结果不一致问题出在哪这是我最常被问到的问题明明在IDEA里跑得好好的粘到头歌上就编译失败或者输出答案不正确。先说编译错误90%是类名、包名、import缺失这三类问题。类名和包名前面说了import缺失也很好理解比如用了Scanner但没写import java.util.Scanner本机IDEA可能会自动补但头歌不会。如果是答案错误那多半是输出格式和题面要求不一致。你可以对比一下本地输出和平台给出的样例哪怕看起来一模一样也要检查隐藏的空白字符。我在第一次做类似题目时就是多了一个末尾空格肉眼完全看不出来后来用十六进制查看才找到问题。4.2 验证码是随机生成的会不会被判错有些同学会担心验证码是随机生成的那评测系统怎么知道我输出的“对”还是“不对”这个问题我在辅导时解释过很多次头歌这类实验评测系统一般不会去校验你输出的是不是“合法验证码”它校验的是程序是否能编译、是否能运行、输出格式是否落在规则范围内。你生成的是A9k2还是X7mP只要长度正确、格式一致就能通过。所以不要为了求稳把验证码写死成固定字符串。那样虽然也能过某一次评测但完全背离了题目考察随机数用法的初衷。万一平台有特殊校验甚至可能会因为你输出固定值而判错。4.3 字符画错位、间距不均匀怎么调字符画版最烦人的就是错位。最常见原因是字模表本身行数不一致。比如A定义了五行B定义了六行按行拼接时后面全乱了。解决办法很简单所有字模统一用五行不足五行的行用“00000”补齐。其次是列间距问题。两个字符字模之间要加固定宽度的空格分隔比如每行输出完第i个字符的当前行后追加两个空格再接下一个字符这样整个图案才会整齐。不要图省事拼完就换行那会变成一长串看不出字形的0和1。还有一个特别隐蔽的问题不同字符在同一行上占位宽度不一致。数字1的字模天然比其他数字窄如果你所有字符都用同样宽度的模板去定义整体视觉是齐的但如果你为了省事让1的字模只写3列其他数字写5列拼出来的图案就会忽宽忽窄。编码阶段就要统一宽度。4.4 一个很实用的本地验证技巧在提交之前我习惯把程序输出重定向到文件里再检查空白字符java Main out.txt然后在IDEA或VS Code里打开out.txt看每一行的结尾有没有多余的空格看最后一行有没有额外的空行。如果平台的在线评测提供样例输出就把本地输出和样例输出做一个diffdiff结果为空基本就稳了。这个方法虽然简单但能帮你少走很多弯路。很多同学提交失败后喜欢反复改代码改来改去也不知道错在哪里其实问题往往不在逻辑而在那些看不见的格式细节上。5. 从过实验到能上线验证码的校验与防刷设计5.1 服务端校验存储、忽略大小写、一次性失效实验题做到这里算完成了一半但如果你去面试Java开发岗面试官很可能会接着问一句验证码生成出来之后怎么校验最经典的答案是基于Session。用户请求验证码时后端生成验证码字符串把它存进session用户提交表单时后端把用户输入和session里存的做比对。这里有两个细节。第一比对时忽略大小写因为用户很难分清图片里的大写R和小写r第二验证码用完一次立刻删除不能被同一个验证码反复提交否则就失去了防刷的意义。下面这段逻辑很典型String expected (String) session.getAttribute(captcha); if (expected ! null expected.equalsIgnoreCase(input)) { session.removeAttribute(captcha); // 校验通过继续处理业务 }注意equalsIgnoreCase这个API它就是专门为验证码这类“用户手工输入大小写可能不一致”的场景设计的。另外验证码一定要设置有效期不能存在session里就永久有效一般2到5分钟就够了。有效期太长被截获后可以利用的时间窗口越大太短用户还没输完就过期体验又很差。5.2 前后端分离场景验证码与JWT的配合现在很多项目是前后端分离甚至没有Session那验证码就不能简单存在Session里了。常见做法是用Redis或者内存缓存以captchaId作为key。流程是这样的后端生成验证码后把验证码字符串存到缓存里设置5分钟过期同时把captchaId和图片一起返回给前端。用户提交登录时带上captchaId和输入内容后端查出缓存中的值做比对比对成功后删掉这个key。等验证码校验通过服务端再签发JWT返回给前端。你搜索“spa项目开发之jwt验证码实现”时会看到很多类似的代码核心就是这个逻辑。这里有个容易混淆的点验证码与JWT之间没有强绑定。验证码只负责证明“你是人类”JWT负责证明“你是谁”两者各管一段千万不要把验证码编码进JWT里。JWT的签名密钥、过期策略和验证码完全无关该怎么做就怎么做。验证码只是登录链路中的一道前置检查。5.3 更抗机器识别的设计干扰线、噪点与应用平衡图形验证码最容易犯的错是“为了防止机器识别把自己人也挡在门外”。我见过有些项目往图片上画了十几条干扰线再加几百个噪点结果用户认了五次都认不出字符最后只能换一张新验证码体验极差。合理的设计原则是干扰元素要能增加机器识别的成本但不能影响字符的主体轮廓。常见的做法是画少量随机位置和角度的干扰线段加几十个随机噪点字符颜色使用深色而背景保持浅色。你观察一下真正成熟的验证码组件字符一般都带有轻微扭曲或整体倾斜干扰线和背景色块反而不会太多这就是“可读性”和“安全性”之间的平衡。回到实验本身头歌一般不会要求你做这么复杂的图像处理但理解这个平衡逻辑对你以后接手真实项目会有帮助。尤其是做登录注册、短信发送这类功能时图形验证码往往是短信接口防刷的第一道闸门如果你能说清楚“为什么画三条干扰线而不是十条”面试官会觉得你是真做过这个功能不是只会背概念。最后说一点个人体会吧。我见过的学生里能把这类实验题做出来的人很多但能说清楚“验证码为什么要过期”“为什么要忽略大小写”“为什么图形验证码要加干扰线”的人很少。我的建议是不要只把这当成一个拿学分、过测评的任务你多问自己几个为什么然后顺着校验、缓存、图形绘制这条线往下钻它就能从一个头歌实验题变成你简历里真正拿得出手的“登录验证模块”。如果你现在做的实验卡在了输出格式上记住一句话先看题面再看样例最后再看代码顺序反了会平白多花很多时间。
返回列表