80分钟100分,83行代码决赛优秀选手如何解题?
Posted 云效DevOps
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了80分钟100分,83行代码决赛优秀选手如何解题?相关的知识,希望对你有一定的参考价值。
由阿里云云效主办的2021年第3届83行代码挑战赛已经收官。超2万人围观,近4000人参赛,85个团队组团来战。大赛采用游戏闯关玩儿法,融合元宇宙科幻和剧本杀元素,让一众开发者玩得不亦乐乎。
本次大赛最后一道题考验的是参赛者的Debug能力,最好对基于springboot的spring webflux(至少是spring mvc),spring security有一定了解,可以省去很多在比赛过程中查找资料的时间。
下面站在一个对上述架构不那么熟悉的角度, 按步骤讲解debug思路. 对于以后了解spring全家桶还是很有帮助的。
第一步
Bug1
ReactiveWebSocketHandlerTest单元测试调试,首先执行单元测试,查看执行结果。
调用栈从上向下分析,看到是一个EOF错误,即流在读取完毕后仍然尝试读取,找到错误栈中最近的com.aliyun.code83开头的源代码上下文。
private static CheckedFunction<DataInputStream, String> charsetNameDecoder = (DataInputStream input) -> {
byte[] charsetNameBytes = input.readNBytes(input.readByte()); //源代码第100行
if (charsetName.get() == null) {
charsetName.set(new String(charsetNameBytes, ISO_8859_1));
}
return charsetName.get();
};
可以看到错误出在第100行的input.readByte()中,但是在上文中并没有其他的input操作,说明这个流在传入时就已经被读光了,顺着错误栈继续向上找,Utils的89行上下文。
public static String decodeMessage(byte[] rawMessage) {
ByteArrayInputStream in = new ByteArrayInputStream(rawMessage);
DataInputStream dis = new DataInputStream(in);
try {
return new String(dis.readAllBytes(), charsetNameDecoder.apply(dis)); //源代码第89行
} catch (IOException e) {
e.printStackTrace();
return String.format("%s<_>-<_>.", e.getClass().getSimpleName()); // 此行勿动,影响评分
}
}
从89行可以看到:
charsetNameDecoder.apply(dis) 报了错,原因是new String的第一个参数dis.readAllBytes()已经把数据读完了,这里我们需要大概分析一下这部分代码的功能。
new String如果传入2个参数的话,第一个参数bytes[]是对应内容的byte数组,而第二个参数是字符集。这里看起来无论是字符串内容还是字符集都来自于input输入流,通过charsetNameDecoder逻辑来看,先通过input.readByte读出一个长度N来,在通过readNBytes读出长度N的部分解析出字符集,再把剩余的部分读出来按照字符集进行字符串构建。
如果完整读过所有代码的话,也可以从ReactiveWebSocketHandler的javadoc上看到包的格式,这里也体现出javadoc的重要性,做题前也许代码不用读完,但是尽量把javadoc都过一下。
/**
* 二进制包格式
* byte 字符集长度; n1
* byte[n1] 字符集数据; n1 = 字符集长度
* byte[n2] 有效数据;n2 = 包总长度 - n1 - 1
*/
@Component("ReactiveWebSocketHandler")
public class ReactiveWebSocketHandler implements WebSocketHandler {
...
这里问题明显出89行上, readAllBytes提前把所有数据都读完了, 所以代码调整如下
public static String decodeMessage(byte[] rawMessage) {
ByteArrayInputStream in = new ByteArrayInputStream(rawMessage);
DataInputStream dis = new DataInputStream(in);
try {
//先从流中前部分读出字符集, 剩下的再通过readAllBytes读出
final String charset = charsetNameDecoder.apply(dis);
return new String(dis.readAllBytes(), charset);
} catch (IOException e) {
e.printStackTrace();
return String.format("%s<_>-<_>.", e.getClass().getSimpleName()); // 此行勿动,影响评分
}
}
重新运行单元测试,发现ReactiveWebSocketHandlerTest已经没有错误了 (至少满足的unit test的判断期望, 业务上是否有错误未必)。
行第二个单元测试Round4ApplicationTests,看起来是空的,直接成功下一个。
Bug2
执行第三个单元测试UtilsTest。
执行失败,看起来是一个字符串处理的逻辑,而处理的结果不太对。
下面观察一下测试用例:
Triplet.with(
"提取普通文本",
"Welcome to <pre>DevStudio</pre>",
"Welcome to DevStudio"
),
Triplet.with(
"提取CJK文本",
"有<i>对象</i>了么? 别慌, 送你一个! 领取请加钉钉群: <quote>35991139</quote>",
"有对象了么? 别慌, 送你一个! 领取请加钉钉群: 35991139"
),
Triplet.with(
"提取Tag文本",
"<p>Cosy 提效补全用过没, 还能搜搜搜 https://developer.aliyun.com/tool/cosy</p>",
"Cosy 提效补全用过没, 还能搜搜搜 https://developer.aliyun.com/tool/cosy"
),
Triplet.with(
"提取嵌套tag文本",
"<blockquote><p>401?!! 不要慌,不要急,App Observer 帮助您~ https://help.aliyun.com/document_detail/326231.html 了解一下</p></blockquote>",
"401?!! 不要慌,不要急,App Observer 帮助您~ https://help.aliyun.com/document_detail/326231.html 了解一下"
),
Triplet.with(
"万圣节惊喜小剧场",
"<happy>碧油鸡全部退散, 颈腰椎早日康复! </happy>贼真诚",
"碧油鸡全部退散, 颈腰椎早日康复! 贼真诚"
)
从每个用例可以看出,似乎对于处理逻辑的期望是把所有中括号里的元素去掉,就像消除html节点定义,只保留文本内容一样。下面观察一下实际被测试的方法:
private static final Pattern REGULAR_HTML_TAG = Pattern.compile("<(?<tag>.*)>");
public static String stripHtmlTag(String html) {
if (ObjectUtils.isEmpty(html)) {
return null;
}
StringBuilder builder = new StringBuilder();
final Matcher matcher = REGULAR_HTML_TAG.matcher(html);
while (matcher.find()) {
matcher.appendReplacement(builder, Strings.EMPTY);
if (log.isDebugEnabled()) {
log.debug("remove tag {}", matcher.group("tag"));
}
}
return builder.toString();
}
的确, 从整体逻辑看起来是通过正则匹配一对<>, 并替换成空的逻辑. 首先分析最上面定义的正则表达式, 乍一看是OK的, 匹配两端为<>的任意字符.* , ?是给匹配分组命名用的, 对于匹配无直接作用, 是replace时作为group的key对待, 这个具体可以查正则相关文档. 但是UT执行明显有错, 我们把一个用例字符串用这个正则匹配看看
可以看到,正则匹配从第一个<直接到了最后结尾的>,所以执行结果就是整句话替换还剩一个"有"字。这里涉及到正则的贪婪匹配问题,默认为贪婪的,尽可能匹配更多内容,而取消贪婪的做法是在匹配规则后面加一个问号?变成
private static final Pattern REGULAR_HTML_TAG = Pattern.compile("<(?<tag>.*?)>");
bug3
改掉后再执行一次UT
看起来好多了! 只有一个错误了,现在来分析一下为什么错。
public static String stripHtmlTag(String html) {
if (ObjectUtils.isEmpty(html)) {
return null;
}
StringBuilder builder = new StringBuilder();
final Matcher matcher = REGULAR_HTML_TAG.matcher(html);
while (matcher.find()) {
matcher.appendReplacement(builder, Strings.EMPTY);
if (log.isDebugEnabled()) {
log.debug("remove tag {}", matcher.group("tag"));
}
}
return builder.toString();
}
从循环上看,对于html进行tag匹配,找到的话向builder里写入新字符串,而新字符串的内容是截止到匹配部分位置的文本,并且把<(?.*?)>替换为空。针对 "碧油鸡全部退散, 颈腰椎早日康复! 贼真诚" 这个用例。
- 第一次匹配内容是<happy>,向builder里写入替换文本为空;
- 第二次匹配的部分是碧油鸡全部退散,颈腰椎早日康复! </happy>,向builder里追加后的内容是"碧油鸡全部退散,颈腰椎早日康复!"
- 第三次while过来,因为没有新的<.*>内容, 直接结束循环,return了!所以问题出在这里,需要把剩下的部分"贼真诚"补进来。
于是代码调整如下:
public static String stripHtmlTag(String html) {
if (ObjectUtils.isEmpty(html)) {
return null;
}
StringBuilder builder = new StringBuilder();
final Matcher matcher = REGULAR_HTML_TAG.matcher(html);
while (matcher.find()) {
matcher.appendReplacement(builder, Strings.EMPTY);
if (log.isDebugEnabled()) {
log.debug("remove tag {}", matcher.group("tag"));
}
}
matcher.appendTail(builder);
return builder.toString();
}
增加appendTrail,把剩下的部分补进来。运行单元测试, 一切OK!
其实,这里还有个简单写法:
public static String stripHtmlTag(String html) {
if (ObjectUtils.isEmpty(html)) {
return null;
}
return html.replaceAll("<.*?>", "");
}
不过, 既然改bug,那尽量保留原有逻辑为好。
第二步
下面我们开始进行业务调试,按照README提示运行。/round4. 开场就挂了,看到提醒需要启动服务才行。
找到带@SpringBootApplication注解的main方法,这是spring boot程序标准的启动入口,启动, run/debug都可,如果想要断点调试的话, 用debug。
Bug4
再次执行./round4
Step1看起来没啥错,Step2出现错误,看起来是期望动态添加一个用户reporter,失败了,错误消息是缺少CSRF请求头,如果用过spring security (无论在spring MVC还是spring Webflux)的话, 在安全配置里面可能会留下印像,就是对csrf的配置。这里看到round4客户端似乎请求中不带有csrf的token,那我们只能改服务了。
注: CSRF百度一下可以了解它的作用,目的和基本机制,spring security有原生实现,只要通过配置处理就好,csrf功能默认是开启的。
找到安全配置的类WebSecurityConfig,调整配置如下:
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.headers().disable()
.authorizeExchange()
.pathMatchers("/endpoints").hasAnyRole("USER")
.pathMatchers("/users").hasAnyRole("admin")
.pathMatchers("/ws/test").hasAnyRole("TEST") // 该行勿改动,否则影响评分
.pathMatchers("/ws/**").hasAnyRole("admin")
.anyExchange().authenticated()
.and()
.httpBasic()
.and()
.formLogin().disable()
//加入这一行
.csrf().disable()
.build();
}
bug5
重启服务, 再次执行./round4
第二步又挂了,但是错误内容变了,变成了401,看描述是身份凭证不对,错误日志很贴心的打出了错误凭证内容,是basic auth方式,后面有一串字符。
一看=结尾的乱码字符,会比较容易联想到base64。随便找个base64解密工具,把这串文字放进去。
解出来一看,很典型的账号:密码格式,也就是尝试用admin / admin123 作为账号密码处理失败了。回到WebSecurityConfig类检查配置
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("user")
.password("{noop}user")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{noop}admin")
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, admin);
}
看到admin的配置password似乎是admin,至于{noop}是什么意思,如果有精力调试的话,可以跟进去看下,UserDetailService对于密码管理是使用一个PasswordEncoder接口来处理的,因为输入密码时虽然时明文,但是安全起见密码在数据库中要混淆过才可以存储,否则数据库数据泄露的话后果是灾难性的。而PasswordEncoder有很多的实现类,UserDetailService默认使用的是一个叫做DelegatingPasswordEncoder的类,它会根据情况把明文交给不同的PasswordEncoder处理成密文匹配,而这个"情况"就是前面大括号的内容。下面是DelegatePasswordEncoder注册的各种混淆算法。
public final class PasswordEncoderFactories {
private PasswordEncoderFactories() {
}
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
}
可以看到,noop对应的是一个叫NoOpPasswordEncoder的实例,也就是no operation,明文拿来什么都不干直接明文存储或比较,所以这样也方便了我们修改。
总之{noop}可以看不明白是怎么回事,但是看不明白的东西不要碰,只碰明白的,admin还是认识的改成{noop}admin123。
Bug6
重启服务,再次执行./round4
Step2又换个错误继续挂....
这次看到错误是权限错误了,但是到底需要什么权限,从这里看不出来。这时候我们回到Web服务的控制台找线索
看到服务日志中,尝试调用POST /users后,报出了一个403错误,应该跟round4的错误对的上的,也就是说可能是/users接口权限有问题,回到代码查看
public class WebSecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.headers().disable()
.authorizeExchange()
.pathMatchers("/endpoints").hasAnyRole("USER")
.pathMatchers("/users").hasAnyRole("admin")
.pathMatchers("/ws/test").hasAnyRole("TEST") // 该行勿改动,否则影响评分
.pathMatchers("/ws/**").hasAnyRole("admin")
.anyExchange().authenticated()
.and()
.httpBasic()
.and()
.formLogin().disable()
.csrf().disable()
.build();
}
@Bean
public MapReactiveUserDetailsService userDetailsService() {
UserDetails user = User.builder()
.username("user")
.password("{noop}user")
.roles("USER")
.build();
UserDetails admin = User.builder()
.username("admin")
.password("{noop}admin123")
.roles("ADMIN")
.build();
return new MapReactiveUserDetailsService(user, admin);
}
}
从配置中看,admin账号具备一个角色叫做"ADMIN",而上面/users接口的配置是需要权限"admin"。这个不像数据库对大小写不敏感,身份权限这么严谨的东西,一个字母都不 能差,把上面的admin全都改成大写ADMIN。
Bug7
重启服务再试
可喜可贺! Step2 跑通了,不管它干了啥,总之是跑通了! 然后处理Step3,又是权限问题,但是我们不知道到底是什么问题,如果账号密码错误的话是401,而且reporter是Step2动态加进去的,要是密码对不上那也没办法调整。
从刚才Step2调试经验来看,403 Forbidden的原因很可能出在权限上面,但是我们也不知道reporter的权限是啥,这时候就要给/users打个断点了,看看Step2到底放进来了个啥。
找到Round4Controller,addUser方法打个断点,执行./round4
看到Step2调用接口是参数username是reporter,密码是reporter,还有一个特别的字段,叫做authorities,内容是ROLE_REPORTER,看起来很可疑,似乎是给这个用户赋予权限。
而Step3调用的接口是/ws/DevStudio、/ws/Cosy、WebSecurityConfig中匹配的规则是