由阿里云云效主办的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,调整配置如下:
@Beanpublic 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类查看配置
@Beanpublic 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中匹配的规定是
.pathMatchers("/ws/**").hasAnyRole("ADMIN")
目前服务器的配置来看,只有ADMIN角色能够拜访,那就须要给它开后门了,看看hasAnyRole是个数组类型参数,追加一个值"ROLE_REPORTER"
.pathMatchers("/ws/**").hasAnyRole("ADMIN","ROLE_REPORTER")
再试,这次应该成.....又挂了! 谬误还是一样?
那就是说方才增加的角色不对?
这时候察看一下配置,其余的角色都叫ADMIN、USER、TEST就是这个后面加了个ROLE_很奇怪,而且在POST /users中,这个值放在一个叫做authorities的数组里名字很宽泛,没有特指Role,会不会像方才的{noop}ADMIN123一样,后面的ROLE_是一个潜规则?如果是个PERMISSION_之类的话就是权限了?那这么说这个角色可能就是叫做REPORTER,从新调整配置
.pathMatchers("/ws/**").hasAnyRole("ADMIN", "REPORTER")
再试! 可喜可贺, 有变动了! 而且提醒能够打分了,调用./round4 --submit,忽视掉三个灵魂问题后,总算有分数了,然而显著问题多多。看来,只是走通了,然而后果不尽如人意。
Bug8
从新执行./round4 察看输入,能够看到满外面乱码,而且简直没有一个失常的中文,联合后面Unit Test调试,能够猜想这可能跟字符集解决无关,也就是Utils那个类应该还是有bug。
这时候从输入也大略能看进去些这个程序的目标了, 仿佛是承受round4的申请, 输入一些文本。而这些业务接口入口就是/ws/**。
开始寻找/ws/**到底是怎么映射到这些办法的,首先在WebConfig找到可疑办法
@Autowired @Qualifier("ReactiveWebSocketHandler") private WebSocketHandler webSocketHandler; @Bean public Map<String, WebSocketHandler> webSocketUrlMap() { return Utils.randomWords(3) .stream() .map(w -> "/ws/" + w) .collect(Collectors.toMap(Function.identity(), w -> webSocketHandler)); }
仿佛注册了一个Map类型的Bean,key是/ws结尾的地址,value是WebSocketHandler,而 WebSocketHandler 的定义中,申明了在 spring 容器中,它的名字是ReactiveWebSocketHandler。接着全文寻ReactiveWebSocketHandler 文本,发现另一个可疑的类。
Component("ReactiveWebSocketHandler")public class ReactiveWebSocketHandler implements WebSocketHandler { @Override public Mono<Void> handle(WebSocketSession session) { return session.send( session.receive() .map(WebSocketMessage::getPayload) .map(getBufferConverter()) .map(Utils::decodeMessage) .map(Utils::stripHtmlTag) .log() .map(session::textMessage)); } private Function<DataBuffer, byte[]> getBufferConverter() { final byte[] buffer = new byte[1024]; return (DataBuffer dataBuffer) -> { int length = dataBuffer.readableByteCount(); dataBuffer.read(buffer, 0, length); return buffer; }; }}
看起来很有关系,handle办法看起来是解决文本的,至于Mono是什么,baidu一下和WebFlux无关,兴许不是太懂,然而看着一连串的map办法,如果java8的个性相熟的话,很像Collections.stream()前面或者Optional类中。map的应用办法,是一连串的映射逻辑,从办法名大略猜测各自的性能:
- WebSocketMessage::getPayload 取得申请体;
- getBufferConverter() 转换成个buffer;
- Utils::decodeMessage 解码;
- Utils::stripHtmlTag 去掉tag;
- log() 打印日志;
- session::textMessage 向会话输入文本;
其中1,6都是spring的办法,出bug的可能性微不足道,log个别也出不了啥错,问题可能就在2,3,4下面。
首先乱码一大片,感觉少不了decodeMessage的干系,剖析源代码:
public static String decodeMessage(byte[] rawMessage) { ByteArrayInputStream in = new ByteArrayInputStream(rawMessage); DataInputStream dis = new DataInputStream(in); try { final String charset = charsetNameDecoder.apply(dis); return new String(dis.readAllBytes(), charset); } catch (IOException e) { e.printStackTrace(); return String.format("%s<_>-<_>.", e.getClass().getSimpleName()); // 此行勿动,影响评分 } } private static final ThreadLocal<String> charsetName = new ThreadLocal<>(); private static final CheckedFunction<DataInputStream, String> charsetNameDecoder = (DataInputStream input) -> { byte[] charsetNameBytes = input.readNBytes(input.readByte()); if (charsetName.get() == null) { charsetName.set(new String(charsetNameBytes, ISO_8859_1)); } return charsetName.get(); };
这两个类做过Unit Test,阐明硬伤不大,然而代码行数不多,缓缓剖析。
decodeMessage逻辑绝对清晰,看起来没大问题,那持续看charsetNameDecoder。
charsetNameDecoder很特地的是用了一个ThreadLocal来存储信息,而WebSocket是不是每次申请都用新的线程来解决兴许不分明,会不会因为线程池重用导致ThreadLocal被净化也不那么分明,然而至多我吃力解析出了charset,仅因为threadlocal存在数据就不必我解析的了就不太对啊。
从这代码上的意思看起来像,如果能解析出charset最好,如果解析不进去的话,用之前解析进去的,所以依照这个思路调整代码:
private static final CheckedFunction<DataInputStream, String> charsetNameDecoder = (DataInputStream input) -> { byte[] charsetNameBytes = input.readNBytes(input.readByte()); if (charsetNameBytes != null && charsetNameBytes.length > 0) { charsetName.set(new String(charsetNameBytes, ISO_8859_1)); } return charsetName.get(); };
再试!
Bug9
可喜可贺,能够看得出来后果中的乱码显著缩小,而且呈现了失常的中文,阐明字符集的批改有了成果,而且从输入看,仿佛在尝试输入一些java代码。
再仔细观察乱码的法则,仿佛都是在每一行的最初呈现,乱码后面的局部看起来都还好。会不会是流太长的起因?
转向前一个步骤ReactiveWebSocketHandler::getBufferConverter
private Function<DataBuffer, byte[]> getBufferConverter() { final byte[] buffer = new byte[1024]; return (DataBuffer dataBuffer) -> { int length = dataBuffer.readableByteCount(); dataBuffer.read(buffer, 0, length); return buffer; }; }
看起来是构建一个长度为1024的数组,而后把length长度的内容填充进去... 等等?为啥晓得length了还固定搞个1024?10月24号很吉利还是咋的?
改了再跑!
private Function<DataBuffer, byte[]> getBufferConverter() { return (DataBuffer dataBuffer) -> { int length = dataBuffer.readableByteCount(); final byte[] buffer = new byte[length]; dataBuffer.read(buffer, 0, length); return buffer; }; }
Bug10
乱码都隐没了! 仿佛都OK了! 这时候扫一眼web服务的控制台,咋输入了一堆谬误?
经典谬误NPE,看这一排的onNext就感觉跟那一排的map有关系,难道说传下来的数据不能有null?每个步骤检查一下,在Utils::stripHtmlTag找到可疑代码。
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();}
如果传入html是空的,返回一个null,何必呢,把null改成 ""再试。
终于没有乱码和谬误了,提交评分! 90分!
持续找bug! 找了有10分钟也没看出哪里错来,而后无奈再看了下README,发现最初10分是那3个问题的分数...
不看文档害死人啊...
至于最初三个问题,我是缓缓试出来的,正确答案请看主办方的解读吧...
最初
在理论工作当中,是很禁忌在没有浏览代码搞清性能的前提下debug的,因为平时没有round4给咱们打分,很容易改掉一个bug,又带来一群新的。
所以,以上的攻略是基于较量环境,在有明确的评分零碎存在时谋求速度的一种做法思路,并不举荐应用在日常工作中,当然debug中寻找谬误的思路是共通的。
如果有短缺的工夫理解业务背景(较量也不会给具体的prd...)和技术思路的前提下,这些水平的bug应该大部分都能够肉眼间接发现排除,当然日常工作中不会有这么多底层的低级bug呈现,如果开发天天面对这种bug,架构师就能够拿来祭旗了...
大赛目前全副关卡凋谢体验,域名地址:https://code83.ide.aliyun.com/,欢送你来。
举荐浏览
1、用代码玩剧本杀?第3届83行代码大赛剧情官网解析
2、无算法不Java,这道算法题很难?
欢送大家应用云效,云原生时代新DevOps平台,通过云原生新技术和研发新模式,大幅晋升研发效率。现云效公共云根底版不限人数0元应用。