ARTICLE DETAIL

资讯详情

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

基于SpringBoot与Spark的用户行为数据挖掘与分析平台实现

基于SpringBoot与Spark的用户行为数据挖掘与分析平台实现 用户行为数据的价值这几年来被反复提及但真正动手把一套完整链路跑通的人并不多。SpringBoot加Spark的组合恰好覆盖了从数据接入、离线计算到Web服务呈现的闭环很适合作为毕业设计或者个人项目练手。这篇文章就围绕“基于SpringBoot与Spark的用户行为数据挖掘与分析平台”展开讲清楚架构设计、环境准备、核心实现和常见坑点。1. 项目定位与整体架构设计1.1 这个选题到底在做什么一句话概括采集用户在产品上的操作日志浏览、点击、收藏、下单等交给Spark做离线批量分析挖掘出用户行为规律再通过SpringBoot构建的后端服务把分析结果暴露成接口最终在Web前端展示出来。很多同学拿到这个题目会纠结一个问题Spark和SpringBoot怎么“融合”其实两者并不需要运行在同一个进程里。Spark负责计算SpringBoot负责应用服务二者通过数据存储层MySQL、Redis、HDFS或者文件接口来衔接。理解了这一点整个项目的架构就清晰了。这个题目的优势在于它同时覆盖了后端开发、大数据处理、数据分析和可视化四块内容答辩的时候能从多个角度展示工作量。而且相比纯增删改查的管理系统这类数据处理项目在技术深度上明显更有竞争力。1.2 技术选型背后的理由选型这件事有些坑是踩过才知道的。SpringBoot作为应用框架生态成熟、上手门槛低和大多数毕设要求的Java技术栈匹配。选Spark而不是Flink主要考虑两点一是Spark的RDD/DataFrame API在离线批处理场景下更直观学习曲线相对平缓二是毕设环境通常性能有限Spark的Local模式或Standalone模式就能跑不需要像Flink那样考虑复杂的流处理状态管理。存储方面MySQL存最终的分析结果和用户基础数据Redis做缓存加速接口响应HDFS如果条件允许就用来放原始日志。内存紧张的机器可以直接用本地文件系统等后期数据量大了再迁移。整个系统的数据流向是这样前端/客户端埋点产生行为日志通过日志采集模块汇聚到指定目录或KafkaSpark定时任务读取原始日志完成清洗和分析结果写回MySQLSpringBoot封装REST接口前端通过Ajax调用接口并渲染图表。1.3 功能模块划分按功能边界平台可以拆成六个模块用户行为采集模块模拟生成或者接收真实埋点数据数据清洗与存储模块对原始日志做ETL剔除脏数据用户行为分析引擎Spark作业实现统计分析与挖掘推荐引擎基于协同过滤算法生成个性化推荐Web服务模块SpringBoot提供REST API可视化展示模块ECharts展示分析结果模块之间尽量解耦尤其是分析引擎和Web服务不要互相依赖。这样任何一边出了问题另一边还能单独工作答辩演示的时候也更灵活。2. 开发环境准备与本机部署2.1 环境依赖清单先列一份我在实际搭建中用到的环境版本仅供新手参考不用完全一致但要保证兼容组件版本说明JDK1.8Spark 3.x要求JDK 8/11考虑到稳定性选的1.8Maven3.6项目构建管理Hadoop3.3.4用本地模式跑不一定要搭集群Spark3.3.2内置Hadoop客户端MySQL5.7存业务数据和分析结果Redis5.0缓存热点数据SpringBoot2.7.x兼顾稳定性和兼容性别选太高版本有个特别容易踩的坑SpringBoot版本别选3.x因为javax包名换成了jakarta很多Spark整合教程和旧代码都不兼容处理起来很费时间。2.7.x加上JDK 1.8的组合网上资料最多遇到问题也好搜。2.2 Spark安装与本地模式验证Spark下载解压后首先要配好环境变量。打开/etc/profile或者用户目录下的.bashrc加上export SPARK_HOME/usr/local/spark-3.3.2 export PATH$PATH:$SPARK_HOME/bin验证安装是否成功spark-shell --master local[2]能进入Scala交互式Shell就说明基础环境没问题。还有一个更轻量的验证方式跑一个最简单的统计任务cd $SPARK_HOME ./bin/run-example SparkPi 10这个命令会计算圆周率的近似值能跑出结果说明Spark的本地运行模式完全正常。对于毕设来说Local模式已经足够支撑开发和演示了不需要去折腾真正的集群。2.3 SpringBoot项目骨架搭建用Spring Initializr生成项目最省事勾选Spring Web、MyBatis、MySQL驱动、Redis这几种依赖。项目结构按标准分层com.example.userbehavior ├── controller // REST接口层 ├── service // 业务服务层 ├── mapper // 数据访问层 ├── entity // 实体类 ├── spark // Spark作业封装 ├── config // 配置类与拦截器 └── common // 通用工具与返回结果封装spark包是整个项目里比较特殊的部分专门放Spark相关的作业类。这样设计的好处是把Spark代码和SpringBoot业务代码隔离避免两者互相污染。尤其要注意不要直接在Service里new一个SparkSession出来后面会讲为什么。3. 用户行为数据的采集与预处理3.1 行为日志格式设计采集端的格式直接决定了解析的复杂度。我建议采用字段分隔符方案用竖线|分隔各字段比JSON更省空间解析速度也快用户ID|商品ID|行为类型|访问时间|设备类型|停留时长(秒)|会话ID|页面来源一条真实的样例数据长这样U10001|P20230101|view|2023-05-12 14:23:01|iPhone|35|S1001|homepage U10001|P20230101|cart|2023-05-12 14:24:56|iPhone|0|S1001|product_detail U10002|P20230210|buy|2023-05-12 15:01:33|Android|0|S1002|search_result行为类型建议规范为固定的枚举值view浏览、search搜索、cart加入购物车、buy购买、fav收藏。分析的时候直接按类型分组不需要再做映射转换。设备类型和页面来源两个字段看起来不起眼但实际上做渠道分析和路径分析的时候非常有用建议一开始就设计进去后期补数据会非常痛苦。3.2 日志生成器的实现思路真实场景中日志来自埋点系统但是毕设环境里最可靠的方式是自己写一个数据生成器。用Java写一个定时任务每隔几秒钟随机生成一批行为数据写入日志文件完全模拟用户操作同时还能控制数据量和行为分布。生成器要控制的几个要点用户ID和商品ID从预先维护的集合里随机选取保证数据能关联到业务表行为类型的概率分布要符合业务常识浏览最多、购买最少浏览占70%左右加购占15%购买只占5%时间戳要尽量贴近当前时间模拟实时产生日志的效果会话ID用UUID或者用户ID加时间戳拼接同一个会话内的行为要有关联性3.3 ETL清洗规则数据进入分析之前清洗是绕不开的一步。常见问题主要有三类空值和缺字段。解析不到指定数量的字段时直接丢弃或者填默认值兜底比如设备类型为空就填unknown。异常时间戳。行为时间在未来、或者时间跨度超过合理范围的数据建议过滤掉。分析历史行为数据时这类脏数据会很影响统计结果。URL编解码问题。如果日志里有搜索关键词等带特殊字符的字段解析时要做URLDecoder处理否则中文关键词会显示成一串百分号编码。小提示清洗阶段做不做数据质量报告可以体现细节。比如统计一下清洗前后数据量的变化、各类脏数据的占比这些数值在毕业设计答辩的时候很有说服力。4. Spark核心分析任务实现4.1 SparkSession初始化与作业封装这是整合SpringBoot时最容易出错的地方。很多同学第一反应是写一个配置类直接注入一个SparkSession的Bean。但实际运行时会发现SparkSession的实例一旦初始化就会绑定一堆资源和线程池如果放在Spring容器里做成单例整个应用启动就变得非常重而且作业提交时机不好控制。更稳妥的做法是单独写一个工具类在需要跑分析任务的时候才创建SparkSession任务跑完立即关闭比如public class SparkJobUtils { public static SparkSession createSession(String appName) { return SparkSession.builder() .appName(appName) .master(local[*]) .config(spark.sql.shuffle.partitions, 4) .config(spark.default.parallelism, 4) .getOrCreate(); } public static void stop(SparkSession spark) { if (spark ! null) { spark.stop(); } } }之所以要用getOrCreate()而不是builder()直接创建是为了避免重复调用时出现“SparkContext已经存在”的异常。4.2 基础统计类指标的实现基础统计指标包括PV/UV、热门商品TopN、用户活跃时段分布等这些用Spark SQL就能解决核心是看懂逻辑而不是抄代码。PV/UV计算DatasetRow df spark.read().textFile(inputPath) .map((MapFunctionString, String) line - { String[] parts line.split(\\|); return parts.length 4 ? line : ; }, Encoders.STRING()) .filter(value ! );更高效的方式是直接用DataFrameReader按分隔符读进来之后做groupBy。写几个示例PV统计DatasetRow pvResult df.groupBy(behaviorType) .count() .withColumnRenamed(count, pv);UV统计实际上是用户去重计数DatasetRow uvResult df.select(userId, behaviorType) .dropDuplicates(userId, behaviorType) .groupBy(behaviorType) .count() .withColumnRenamed(count, uv);热门商品排行DatasetRow hotGoods df.filter(col(behaviorType).equalTo(view)) .groupBy(goodsId) .count() .orderBy(col(count).desc()) .limit(10);这里的dropDuplicates就是SQL里的DISTINCT理解成去重就行。用户活跃时段分布要用hour()函数从时间字段中提取DatasetRow hourActive df.withColumn(hour, hour(col(actionTime))) .groupBy(hour) .count() .orderBy(hour);这些都是很常规的分析但加一些细节就出彩了。比如把结果按日期分区存储或者统计结果落到MySQL时增加一个analysis_date字段区分是哪一天的分析数据。4.3 用户转化漏斗分析漏斗分析是很直观地体现用户行为规律的分析方式。电商场景下的经典漏斗是浏览→加购→下单→支付。在Spark里实现漏斗核心思路是计算每一步的用户数然后用上一步用户数作为基数计算转化率。long viewUsers df.filter(col(behaviorType).equalTo(view)) .select(userId).distinct().count(); long cartUsers df.filter(col(behaviorType).equalTo(cart)) .select(userId).distinct().count(); // 下单和支付类似按同样方式统计 double viewToCartRate (double) cartUsers / viewUsers;要注意的是这只是一个比较粗粒度的漏斗。真实场景里面用户可能没有浏览就直接购买了比如通过链接分享进来这时候漏斗会有衰减异常。如果想做得更精确需要按用户的完整行为路径去匹配“先浏览再加购再下单”的用户子集这就要用RDD的map操作配合自定义数据结构了工作量会大不少。毕设阶段粗粒度漏斗已经完全够用。4.4 用户画像标签生成用户画像是用户行为分析里很受关注的一个模块。可以给每个用户打上几类标签消费力等级、活跃度等级、品类偏好、设备偏好。消费力等级可以按购买金额和购买频次综合判断简单的方式是把用户的总消费额分位数划分为高、中、低三档。活跃度等级可以按用户的活跃天数来判断比如近30天活跃15天以上就是高频用户5天以下算低频。品类偏好的实现思路是把用户对每个品类的浏览和购买行为加权计分购买行为的权重远大于浏览然后选出得分最高的品类作为该用户的偏好品类。代码上可以这样组织逻辑// 计算每个用户的品类偏好得分 DatasetRow userPref df.groupBy(userId, category) .agg( sum(when(col(behaviorType).equalTo(buy), 5) .otherwise(when(col(behaviorType).equalTo(cart), 2) .otherwise(1))).as(score) );这段SQL表达式的意思是购买计5分加购计2分其他行为计1分累加。然后对每个用户按分数降序取第一名。4.5 协同过滤推荐算法实现推荐模块是整个项目的含金量所在。ALS交替最小二乘法是Spark MLlib里实现协同过滤的经典算法广泛用于矩阵分解。代码的核心逻辑并不复杂但思路要清楚ALS als new ALS() .setMaxIter(10) .setRank(10) .setRegParam(0.1) .setUserCol(userId) .setItemCol(goodsId) .setRatingCol(rating) .setColdStartStrategy(drop); ALSModel model als.fit(trainingData); DatasetRow recommendations model.recommendForAllUsers(5);这里有个关键参数要说明rating评分不能直接用buy、view这种字符串必须转成数值。我的做法是给不同行为赋权重购买5分加购3分收藏2分浏览1分然后构建成Rating数据。setColdStartStrategy(drop)的作用是当模型遇到训练集中没有出现过的用户或商品时直接丢弃预测结果避免产生NaN值。这个配置一定要加否则预测结果里会出现大量空值写回数据库的时候就会报错。rank和maxIter两个参数是调优的关键。rank控制特征向量的维度值越大模型表达能力越强但过拟合风险也越高maxIter是迭代次数。毕设场景不需要做严格的交叉验证用经验值rank10、maxIter10、regParam0.1效果已经可圈可点。5. SpringBoot后台服务与整合对接5.1 分析结果落库Spark分析结果最终要供SpringBoot查询落库这一步非常关键。用foreachPartition批量插入MySQL比逐条插入快一个数量级result.foreachPartition((ForeachPartitionFunctionRow) iterator - { Connection conn DBUtils.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO user_behavior_analysis (user_id, goods_id, behavior_type, analysis_type, result_value, analysis_date) VALUES (?,?,?,?,?,?) ); while (iterator.hasNext()) { Row row iterator.next(); ps.setString(1, row.getString(0)); // ... 其他字段 ps.addBatch(); } ps.executeBatch(); conn.close(); });连接管理要注意每个分区都要获取自己的连接分区间的连接不能共享JDBC连接对象不是线程安全的。用连接池比如HikariCP会更稳妥但简单实现直接每次获取和关闭也能用关键是用batch提升写入效率。还有一条规律值得遵循要保证Spark作业能重复执行而不产生脏数据。在写入MySQL前先删除当天的分析记录再插入新的结果这样才能保证数据一致性。5.2 SpringBoot查询接口封装接口设计要做到职责清晰。建议按分析主题拆分成不同的ControllerRestController RequestMapping(/api/analysis) public class AnalysisController { GetMapping(/overview) public Result getOverview(RequestParam String date) { return service.getOverview(date); } GetMapping(/hot-goods) public Result getHotGoods(RequestParam String date, RequestParam int topN) { return service.getHotGoods(date, topN); } GetMapping(/funnel) public Result getFunnelData(RequestParam String date) { return service.getFunnelData(date); } GetMapping(/recommend/{userId}) public Result getRecommendations(PathVariable String userId) { return service.getRecommendations(userId); } }统一返回Result包装类是很有必要的它保证了每个接口的结构一致格式类似{code: 200, message: success, data: {...}}。前端拿到数据后无需处理异常结构集成体验会舒服很多。5.3 Redis缓存热点数据用户行为分析结果有一个特点同一个日期的数据会被反复查询。每次查询都打MySQL显然不划算在SpringBoot里集成Redis做缓存是常见的优化手段。最简单的方案用Spring Cache的注解方式Cacheable(value hotGoods, key #date - #topN) public ListHotGoodsDTO getHotGoods(String date, int topN) { // 查询MySQL执行真正的业务逻辑 }要注意一个问题Spark作业每次更新分析结果后Redis里的旧缓存会残留。解决方法是在Spark作业跑完入库后清理对应日期范围的缓存比如调用redisTemplate.delete(key)或者直接让缓存过期时间设为当天晚上12点redisTemplate.expireAt(hotGoods, LocalDate.now().plusDays(1).atStartOfDay());5.4 Spark作业触发机制的选择SpringBoot工程里如何触发Spark作业有几种方案各有利弊。方案一是手动触发提供一个/api/analysis/run接口后端接收请求后启动一个异步线程执行Spark作业返回提示信息表示开始运行。这种方式的缺点是执行时间较长时前端会一直等待体验一般但是实现简单比较可控。方案二是定时触发用Spring自带的Scheduled注解配置cron表达式每天凌晨执行一次分析。比如Scheduled(cron 0 0 2 * * ?) public void runDailyAnalysis() { sparkJobService.executeDailyJob(); }这样每天凌晨2点自动跑前一天的数据分析。在真实场景中这种模式很常见凌晨计算资源空闲正好用来跑批量作业。但要注意Spring定时任务是单线程的同一时刻只能执行一个任务多个Spark作业塞在一起需要自己做好串行排队。方案三是事件触发模拟数据生成器写完一批日志后发送一个ApplicationEvent监听器收到事件后再执行Spark分析。这种方案设计感强但实现稍微复杂。我最终选的是方案二和方案一结合手动触发用于演示定时任务用于日常数据更新。手动触发有一个好处答辩现场可以现场点击运行过程中给评委看Spark UI界面上的作业进度效果相当不错。6. 可视化大屏展示模块6.1 前端技术选型前端部分我用的是Vue加ECharts通过Nginx或者直接在SpringBoot里托管静态资源。考虑到答辩时的稳定性尽量把打包后的前端文件放在SpringBoot的resources/static目录下这样只需要启动一个后端服务前端访问http://localhost:8080就能看到页面不需要额外启动前端开发服务器避免多开服务的繁琐。6.2 核心图表设计大屏页面上建议编排以下几个图表区域顶部指标卡展示今日PV、今日UV、订单数、转化率四个核心指标数字配上时间段内的小趋势线条行为分布饼图不同类型行为的占比环形图24小时活跃度折线图横轴是小时纵轴是用户操作次数热门商品Top10柱状图横向柱状图更利于展示商品名称用户渠道占比图设备类型的占比堆叠图推荐结果列表展示某个用户的前5条推荐结果每个图表对应一个接口前端用Axios统一请求拿到数据后直接设置ECharts的option。6.3 前端与后端的联调注意事项前后端联调有个常被忽视的细节跨域问题。如果前端和后端分端口运行必须在SpringBoot里配置CorsFilter否则浏览器拦截请求数据永远加载不出来。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); UrlBasedCorsSource source new UrlBasedCorsSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }把前端打包进SpringBoot静态目录可以在一定程度上规避跨域问题但配置好Cors仍是必要的因为调试阶段可能会临时启用独立的Vue开发服务器。7. 常见问题与排错经验总结7.1 Spark作业提交后一直报Task not serializable这个报错太经典了很多第一次写Spark算子的人都会撞上。原因在于Spark算子内部的匿名函数会捕获外部对象而整个函数要序列化后发送到Executor执行如果捕获的对象没有实现Serializable接口就会报错。解决方案有几个思路不要在算子内部引用Spring容器中的Bean比如Service、Mapper这些把需要用到的数据先提取成基础类型String、Integer再传进算子自定义类全部实现Serializable或者用foreachPartition在分区内创建连接这样整个分区共享一个连接减少了序列化负担。7.2 内存不足导致Executor Lost本机跑Spark默认会占用大量内存毕设机器配置不高时就容易OOM。解决方案是调整Spark的内存参数在创建SparkSession时手动指定.config(spark.executor.memory, 512m) .config(spark.driver.memory, 512m) .config(spark.memory.offHeap.enabled, false)另外一个非常有效的办法把spark.sql.shuffle.partitions调小。默认是200个分区对毕设这个数据量来说太多了每个分区处理的数据很少反而产生大量文件碎片和调度开销改成4或8个分区能显著降低内存压力。还可以用repartition()或者直接通过调节spark.default.parallelism来控制并行度。如果数据量只有几千条设置过多的并行度纯粹是一种资源浪费。7.3 Spark版本和SpringBoot版本的兼容性冲突这是一个足够让人崩溃一整晚的坑。Spark自带的Jackson版本比较老而SpringBoot 2.7.x默认用的是Jackson 2.13Maven依赖解析时高版本覆盖低版本导致Spark内部某些序列化逻辑跑不起来。解决办法有两种第一种是在pom.xml里把SpringBoot的Jackson相关依赖排除掉让Spark自带的Jackson生效但这样SpringBoot的JSON处理可能又会出问题第二种是统一升级Jackson到两者都兼容的版本比如2.12.x或2.13.x需要保证jackson-databind、jackson-core、jackson-annotations三个模块的版本完全一致。我最终的做法是为Spark相关的Maven依赖单独声明版本同时排除SpringBoot传递的Jackson依赖给Spark只保留它自己需要的版本dependency groupIdorg.apache.spark/groupId artifactIdspark-sql_2.12/artifactId version3.3.2/version exclusions exclusion groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /exclusion /exclusions /dependency7.4 MySQL连接占用过多导致连接数耗尽Spark写入MySQL时如果每条数据都创建一次连接会产生非常夸张的连接开销MySQL服务端很快报Too many connections。解决办法就是用foreachPartition加addBatch批量提交分区内共用一条连接。这能让连接数从“记录数”降到“分区数”通常只有个位数。如果数据量大还可以在批量提交后关闭连接前设置合理的rewriteBatchedStatementstrue参数来进一步加速写入String url jdbc:mysql://localhost:3306/db?rewriteBatchedStatementstrueuseSSLfalse;7.5 Spark UI无法访问Spark跑起来后默认Web UI端口是4040。如果在SpringBoot里直接启动Spark作业SpringBoot的内嵌Tomcat端口占用的可能性不高8080但要注意如果配置了多个SparkContext实例4040端口被第一个占用后第二个会尝试4041。所以正常访问http://localhost:4040应该能看到作业列表。在提交作业时作业运行完毕SparkContext关闭UI界面就消失了。如果想在答辩时演示UI可以保持SparkContext不关闭比如把作业提交和页面展示做成异步的SparkContext保持存活期间UI一直可用。另一种方式是配置spark.ui.port4040固定端口同时确保作业时间足够长。7.6 数据量少导致的分析结果没有区分度模拟数据量如果只有几千条很多统计分析的结果会非常“平”比如每个小时的活跃度都差不多TopN商品反复出现同一类看起来缺乏真实感。建议生成数据时在(0, 24)小时范围内设置不同概率权重比如19点到22点概率翻倍模拟晚高峰商品的曝光量要符合长尾分布少数爆款占据大部分流量用户行为序列要按业务逻辑衔接浏览之后才可能出现加购和购买不能出现“没有浏览直接购买”的跳跃把数据生成器设计得合理分析结果才有故事可讲演示起来也更有说服力。8. 经验总结与扩展建议8.1 毕设时间分配的心得一套完整的Spark加SpringBoot用户行为分析平台按每天有效开发8小时估算比较现实的时间分配是环境搭建和原型验证3到5天主要花在Spark环境、版本兼容上日志生成和数据清洗2到3天Spark分析作业开发调试5到7天这部分最耗时需要反复调优SpringBoot接口和缓存优化3到4天可视化大屏3到5天文档撰写和答辩PPT3到4天总工期大概在20到30天留出缓冲余地不要压着截止日期赶工。Spark作业的调试时间很容易被低估看似简单的聚合操作实际跑的时候会因为数据格式、空指针、序列化等问题反复返工。8.2 哪些扩展方向会让项目更有亮点这个题目虽然叫“用户行为数据挖掘”但可以做的延展非常丰富。接入实时计算链路是含金量较高的方向。如果时间充裕可以把日志发送到Kafka再用Spark Streaming或者Flink做实时指标计算这样系统就从离线分析升级为离线加实时双链路。加入机器学习模型是另一个出彩的方向。除了协同过滤推荐之外还可以做用户流失预测模型用逻辑回归对用户特征分类准确率达到75%以上就能作为亮眼的成果。这部分不需要特别复杂的调参数据特征做扎实才是核心。引入OLAP引擎也是深度方向之一。分析结果如果数据量巨大MySQL的查询性能会吃紧。把Spark分析结果直接写入ClickHouse或者DorisSpringBoot查询的响应速度就是另一个量级面试官对这个方案通常非常认可。8.3 个人体会做这个项目最大的感受是不要一开始就追求复杂的集群架构。单人开发、单机演示的场景下Spark直接跑Local模式数据量几万到几十万条整个流程完整体验下来核心收获已经拿到了。真正重要的是把“数据从哪来、经过什么处理、展示成什么结果”这条链路完整落地每一步都能讲清楚设计与取舍。另外日志生成器要顺手写好因为整个开发周期你会反复用它来造数据、做测试。不要觉得模拟数据无所谓数据分布不合理后面所有的分析结果都不对劲。数据生成器本身也是项目的一部分完全可以在文档里当作一个模块来写。最后说一点实际的答辩时不要只讲“我用了Spark”要讲清楚“为什么用Spark而不是直接SQL统计”讲几条基于数据量的真实对比说明你的选型是思考过的。面试官和评委更在意的是你对每个技术决策背后原因的理解深度而不只是代码能否跑通。
返回列表