最新动态
- 《骑行密云水库》视频制作
上周六骑行密云水库,特意多带了一个手机,架在车把前方,拍下了整个旅程。真的是好不容易录的(手机还行,人不容易),所以很想剪一个视频出来,自己有点成就感,也想分享给家人朋友。 这是我第一次剪视频,是一次未知的挑战。这是一项工程,有明确的验收目标:内容上,表达骑行的所见和所想,质量上,要有精心的设计,能通过我自己的审美标准;有明确的交付时间:最晚本周日晚上,因为过了这个时间,就没意思了。 先做基本构思。素材主要是行路画面,所以自然想到《平凡之路》的MV,这首歌也符合那天的心境,这在骑行过程中就定好了。骑行是一个线性的过程,因此要有头、有尾,出发→到达→返程→到家,阶段明确,分镜总体按时间顺序编排。 周中做了一些准备工作,周六下雨,正式开干! 因为没有经验,全靠理性在摸索,所以周六干得很艰难。总结一下周六做对了的事情: 把素材完整捋过一遍,哪个视频,有什么内容、重要场景、重要画面,按时间顺序一一写出来。计算机领域这叫“索引”,这样后面查找需要的内容就有依据、效率高了。否则,素材茫茫多,无从下手。 放弃模仿韩寒的《平凡之路》MV分镜设计,因为除了歌曲和行路外,其实完全不同,并不适合。我重新单独听歌曲,按感觉对歌曲分段,写出哪一段适合对应我的哪一段,再细化到每句歌词、每段间奏的内容。 先粗剪出第一版再说。在大致符合设计思路的前提下,先粗略地把整首歌按合适的分镜断点都配上画面。第一版是一个骨架。没有这个骨架,想法是发散的、无穷无尽的,这通常是眼高手低干不成事的前兆。有了第一版,就收敛到了一个基线上,后面无非是慢慢改嘛,对可行性、进度都有了一个心理锚点。 周六晚第一版出来,简直惨不忍睹:歌词画面不搭,内容凌乱,不知所云;原始素材歪斜、难看,尤其是夜景,糊成一坨。累了一天,就干出了这么个玩意儿?我觉得,没戏了,可能这样的素材就是做不出来,可能我就是没有那个能力。 周日睡到自然醒,还是想再看一眼。诶?这怎么感觉,还有救呢?饭也不想吃了,接着干! 词画不搭,那就一点一点重新选画面。素材歪斜,那就旋转调正。素材不够,那就把其他日子拍的挪过来。夜景糊,那就加滤镜。骑速慢分镜短,那就加速。歌词全上画以为很费时,但分镜切好+规则样式其实很快。再做出片头,做出片尾,加上字幕,解决衔接问题。晚上9点,导出第24版时,终于定稿! 发布! 想象中是要搞个大片出来,实际上就是个非常普通的视频,但不管怎么说,它达成了我的工程目标,也是我在现有条件和约束下能做到的最好了。冯骥说,完成比完美更重要。所以,我还是觉得很开心。 剪映是个好软件,功能直观好用,不枉我给它充了一个月的会员。
- 骑行:密云水库
本次骑行是为六月的计划进行的模拟考试,目标是验证我有单日200公里的能力。 时间:5月30日周六。地点:密云水库。里程:110公里×2。时长不重要,均速不重要,完成就是合格。 去程 早上6点起床,7点出发,迎着已经热烈起来的朝阳,我一路向东北方向前进。林荫路、桥洞、城市路、立交桥、施工路、郊区路、河桥、山路,信号灯、车流、摩托、其他骑行者、行人,骑行的过程总是大同小异,不过,体验的就是这个过程嘛。 今日晴空万里,几无片云,密云密云,云不太密啊。阳光持续直射,炫目又烧灼,预报说最高35℃。经过一路的运动和暴晒,中午12点多,我感觉我已经有点中暑了。具体症状是,浑身乏力(也可能是因为体力消耗),有点畏冷(类似于感冒,这就不正常了),吸气时喉咙有寒冷刺激感(也类似于感冒),好在不算太严重,还能动。水库已经不远了,坚持住!上山慢得像爬,爬也要爬到! 下午2点,到达密云水库!榨出最后的体力飞驰穿过笔直的潮河主坝,我多想大喊一声“芜湖”,可发现嗓子喊不出来了。把车子停在一堵矮墙边,我已经没有力气做任何事了,只能也跟着瘫坐在墙根下。自拍一张,眼神都呆滞了。 整整休息了一个小时,才感觉缓了过来。好了,又一次把帽子扔过了栅栏,来时是体力相对充沛的114公里,去时已是一身的debuff,怎么回去,看你咯。 返程 多说无益,上车。 3点出发,仍然是正热的时候,刚压下去的中暑感又缠上身体。回程是西南方向,所以几乎是一路直面太阳,漫长的左堤路右侧甚至没有树荫。风不仅燥热,还是逆风,让本就不富裕的体力雪上加霜。两腿都在抽筋的边缘,只能用交替间歇式蹬车法,蹬几脚,溜一段,每公里均速从正常的二十、十几一度掉到个位数。从未如此期待落山的太阳下,越来越难听见Keep报新的公里数,回家的路似无尽般漫长。 “您已打破自己的最远骑行记录!“Keep告诉我,已经超过上次的160公里了。我随即在路边停了车,瘫软地坐在地上,因为我刚刚在冲刺,这次已经比上次(9小时50分)慢了,但我还是想冲一冲10小时以内,可惜,还是超了2秒。而也就是这个没什么意义的小目标才支撑着我来到这里,157公里的时候我就已经觉得必须停车了。我背对着马路坐在路肩,身后无数去市里的车辆呼啸而过。我真的,太累了,这就是我的极限了吗?我之前不停下来休息,是因为我实际上还能蹬下去,不管多慢,走起来就还有希望,停了就真的遥遥无期了。可是我现在,现在好像,好像真的,真的蹬不动了,真的没有一丝力气了。要叫货拉拉吗?可那样,160公里就白费了,下一次,我仍然要先走过那160公里,那时再止步于此? 休息了大约半个小时,起身,我还是不想放弃,只是得想想办法。高德显示,整好还有60公里,于是我给自己制定了这样的规则: 接下来,每10公里我们设置一个虚拟休息站,那么60公里就一共有5站,分别设在距终点49.9、39.9、29.9、19.9和9.9公里处。只允许在休息站休息,不允许途中休息。每到达一个休息站,可获得10分钟的休息时间。可以自由选择是否进站、站内休息多久,未休息的余额可以累加到后面休息。这个规则像是有神奇的力量(后面分析),最终,我以“两停“策略完成了比赛: 序号 距终点距离 分析 决定 休息时长 休息方法 剩余休息时长 1 49.9 身体还能撑住。现在已经握有10分钟了,心里开始有点底气了,把时间留给后面,可以一次性多休息会儿。 不进站 0 - 10 2 39.9 45公里左右时,腿就感觉快不行了,所以本站必须进站。这一站可以躺着休息,下一站就不一定了,所以稍微休息长一点(15分钟),但还是留5分钟备用。 进站 15 路边有石墩,躺在上面,手、脚悬空,腰有支撑,屁股压强小,最大化休息效率。 5 3 29.9 身体还能撑住。 不进站 0 - 15 4 19.9 到东三环了,身体状态恢复了很多,甚至可以连续蹬车了。19.9,交个朋友,助我到下一站。 不进站 0 - 25 5 9.9 身体状态还行,也许能直接冲刺,但右膝盖刚才突然反应了一下,以防万一,还是进站。 进站 10 在公园矮墙上躺着。 25 晚上11点半,农历十四的明亮月光下,我终于到家了。 牛逼的哥们儿 去程中,在顺义遇到一个骑共享单车的哥们儿,问我的车是山地车还是公路车,说他也准备买一个。然后你猜怎么着,我在水库又遇到了他,还是那辆小蓝车。我们不约而同地互道了一声,“好巧啊“。 后来回家之后,我还专门在地图上查了一下,单程将近50公里。还是你牛逼啊,哥们儿! 成果 打破个人记录!第一次上200公里!证明了我有实施计划的能力,考试通过! 五一的时候,我心血来潮,坐地铁,从最西北的俸伯站,到最西南的燕山站,将北京跑了个对角线。今天,我靠自己的人力,跑了个更长的对角线,哈哈! 感受与分析 今日最大的感受,就是艰难,艰难又狼狈。 印象最深刻的,当然就是最后60公里的“休息站规则“,感谢我最近在看F1,才想到这么个法子。分析一下,这个规则所谓的“神奇的力量“是什么。客观上,① 太阳下山,炎热和暴晒debuff逐渐解除;② 人的体能实际上并没有真正耗尽,只是冲刺那一下透支了。主观上,① 大任务分解成小任务,更容易执行;② 被动地被迫休息,变成了主动地选择休息,心理上更有主动权和控制感;③ 60公里是合适的,因为每到一站,剩余里程数都在平时的轻松可达范围内,始终会感觉快到了,如果是80、90、100+,则此规则行不通。 不管怎么说,我真的做到了。 6月1日周一OS:周六一模一样的无云晴天,去相邻的楼吃饭都觉得晒得难受。周六还是太莽了,没死在路上真是命大。经验与教训 这次戴了袖套,手没事,但头没做保护,脸晒黑了,后颈更明显,下次要注意脸和脖子的防晒。 长途骑行腰难受(难受到周一),可能是背了太多水(怕山里没法买),下次要注意腰水平衡,或者整个货架?
- 汉王Clear 7锦鲤使用体验
优点: 屏幕刷新速度很快,明显快过我之前的Kindle Oasis 2和文石 Poke 4S。 对称式,而非人体工学式,方便手捧着阅读、斜靠着阅读。 PDF用“适应屏宽”+“裁边”+“卷轴”,可以充分占满屏幕来显示PDF的内容,虽然限于屏幕尺寸,PDF的字还是小,但至少可读了。 缺点: WiFi信号较差,2.4G WiFi手机信号满格时,它只有2/4格。 不支持WPA3的热点,连不上,要改成WPA2。 打字反应慢,打快了会漏触,自带输入法和微信输入法都是这样。 PDF不支持横屏。 扫描版PDF不支持批注。 PDF的“OCR重排”功能不如推荐说的那么好用,重排后是文字+图片,没有字体,没有排版,不如只裁边看得舒服。 看大体积漫画(200MB+)时,偶尔点击翻页无反应。
- 骑行:居庸关+八达岭
准备 距5月4号的廊坊骑行也有两周了,本周六是时候要继续训练了。本次骑行的基本目标:保量,至少100公里。 最近在跑步减重,但是跑得不太对,左小腿肚疼,右小腿骨疼,豆包说,可不能再跑了嗷,所以周中就停了,但疼一时半会儿没法恢复,周六就这么上吧,骑车负荷没那么大,应该还行吧。 本来是准备环五环的,刚好100公里。可一查,五环没有辅路,得自己找路线。周五晚上画睡着了都没把路线画明白,周六起床已经是中午,那还是随便找个简单的路线吧,我把目光瞄向了城北。八达岭?往返160公里,我这个状态,是不是有点激进了?十三陵?往返120公里,跟廊坊一样,正好保量,那就十三陵。 看看天气,两个手机都跟我说下午要下雨,你们真是难得统一。不过再看周日,下一天,啊这。今天要是不去,明天就更不会去了,人泡汤和计划泡汤,选一个呗。 去程 还是人泡汤吧。随便吃了点儿,背上包,拿上充电宝,这就出发了。 穿越西边半座城,我一路向北。车轮从无数的大学、院所、园区、建筑、公园旁滚滚而过,杨柳毛毛少了很多,有一种花开遍了全城(后来我知道,那是月季),在人民大学的墙上尤为繁盛。雨水到来之前的北京,真是舒爽。 出北五环时,我谨记上次的经验,补充了几瓶电解质水,防止抽筋,然后走上了漫长的G6辅路。切记切记,档位调低,完赛第一。 来到昌平的一个大立交桥,好了,在你面前是一个分岔口:一,stick with the plan,去十三陵,近在眼前了;二,激进一把,去八达岭,你从来没到过这个距离,现在这个时间这个天气,天黑之前不一定能到,你可能什么都看不到,而返程,甚至可能今天(0点前)都到不了家了。 好吧,其实我压根也没犹豫。不到长城非好汉,那我骑到长城,也能算个好汉了吧? 五点,雨开始落下。我在一座桥下躲了一会儿,桥下还有一家三口,三辆自行车,真好啊。雨一直不停,想到后面还有很长的路,我还是怀着天黑之前到达的希望,又扎进了雨中。于是,从这里开始,我就没有一刻停止淋雨了。 开始进山。 过居庸关村,迎面来了一个骑行的大哥,两个湿人互相看了看。我猜他是从八达岭返回,我猜他猜我是去八达岭,都这个点儿了。没错的,大哥,我猜你猜我猜得对。 上山的路总是艰难的,蜿蜒曲折,不进则退。这路,跟上庐山的公路好像啊。要说跑步还是有点儿用的,至少让我长了点儿耐力。档位调最小了,喘气调最大了,一脚一脚地挪,推车是不可能推车的,还是要脸。 上山20公里,我的右大腿已经在抽筋的边缘了,不过,好歹是到了,终于到了,真的到了。七点,到达八达岭长城的登城口,天色尚明。 此时正好老妈打来视频,我没敢接,这个点儿,在深山里,说出来就不让人放心。 返程 没必要休息了,天黑了,赶快下山最要紧。车灯打开,下山! 20公里的下山,爽到飞起,几乎完全不用蹬车,我的右大腿又回来啦。但是车速还是要控制的,车道也不能乱走,雨天路滑,树叶下的山路已经黑了,对向还有刺眼的汽车,安全第一,安全第一。阴凉的山,湿透的我,狂风冷得我浑身颤抖。一辆MPV中的小姐姐问我,需要雨伞吗?我抹了一脸的水,笑了笑,不用了,谢谢。 又回到了居庸关,关楼和山上的长城,都已被灯光点亮,在暗色的天空和山峦背景上,耀眼夺目。这是我未曾预料到的美,也成了我今天最深刻的记忆。 出山了,剩下的60公里,全凭“回家的诱惑“——无论如何,回家就好。ABS式骑法(两腿各蹬4下,然后溜车一段),回家!档位调高(负荷更大,速度更快),回家!雨小了,汗补上,回家,回家,回家! 23:55,到家! 用家里话说,我是暖水提涨(浑身湿透到淌水)。 成果 刷新记录!160公里!而且,我又一次,没抽筋! 下一次直接模拟考试吧,而且我是不是可以尝试拍视频了?
- backend项目架构设计 - 执行时间分析
做一个复杂的数据计算需求,执行过程被拆分为几十个Service(服务)和Calculator(计算器)类,且在其中又调用了十几个基础数据的Service类。计算逻辑开发好后,结构清楚,但执行时间很长,需要做性能优化。那么,应该优化哪些地方,即哪些地方是性能瓶颈?这需要对执行时间做统计和分析。 计时 我们利用Spring的AOP功能实现对方法做执行时间计时。 例如,对于Service,编写如下切面类: @Aspect @Component @Slf4j public class ServiceTimingAspect { /** * 切点 * <p>切点为{@code com.company.project}包下任意层级的{@code service}包中的所有类的所有方法。</p> */ @Pointcut("execution(* com.company.project..service..*(..))") private void pointcut() {} /** * 环绕通知,执行并计时 * @param joinPoint 连接点 * @return 切点的返回值 * @throws Throwable 切点的抛出对象 */ @Around("pointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 执行并计时 var startTime = System.currentTimeMillis(); Object result = joinPoint.proceed(); var endTime = System.currentTimeMillis(); var duration = endTime - startTime; // 方法签名 var methodSignature = joinPoint.toString(); methodSignature = methodSignature.substring("execution(".length(), methodSignature.length() - 1); logger.debug("方法签名{}耗时: {}ms", methodSignature, duration); // 实参列表 var args = Arrays.toString(joinPoint.getArgs()); args = args.substring(1, args.length() - 1); args = args.replace("\n", "\\n").replace("\t", "\\t"); // 方法调用 var methodCall = methodSignature.substring(0, methodSignature.indexOf("(") + 1) + args + ")"; logger.debug("方法调用{}耗时: {}ms", methodCall, duration); return result; } } 日志 通过配置logback-spring.xml将切面类的日志同时输出到控制台和专门的计时日志文件: <!-- 输出到计时日志文件的Appender --> <appender name="TIMING_LOG_FILE" class="ch.qos.logback.core.FileAppender"> <file>log/timing.log</file> <encoder> <pattern>${FILE_LOG_PATTERN}</pattern> </encoder> </appender> <!-- 切面类的Logger --> <logger name="切面类所在的包名" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> <appender-ref ref="TIMING_LOG_FILE"/> </logger> 统计 编写TimingLogAnalyzer类,通过main()方法执行,读取计时日志文件,统计每个方法签名、方法调用的执行次数、总耗时、最小耗时、最大耗时、平均耗时,然后将统计结果输出到Excel文件中。 分析 在Excel文件中通过排序、筛选、统计图等手段,分析出性能瓶颈的所在,进而有针对性地去做性能优化。 功能增强 对Mapper、Calculator、Controller或者其他类做计时切面类。尤其是Mapper,可以用于分析缓慢的数据库查询。 通过配置和@ConditionalOnProperty控制切面类的装配,线上关闭计时,本地也只在需要分析时才开启计时。
- backend项目架构设计 - 请求级缓存
在开发一个复杂的业务计算需求时,遇到如下问题:业务计算过程很复杂,需要拆分为很多步骤,每个步骤又需要拆分为多个不同职责的类,计算时,这些类之间需要共享很多数据。如果通过方法参数传递,则参数会很多,非常繁琐。如果通过中间数据结构传递,则每个类所需的共享数据不同,中间数据结构不好统一。 解决思路:不传递共享数据,而是每个类自己去获取,需要什么就获取什么,参数只保留最必要的,这样每个类的独立性大大增强,更简洁、清晰。显然,这里有个问题,大量重复获取同一个数据,会严重降低效率。我们可以用缓存解决这个问题,对于同一个数据,只在第一次获取时查库,后续直接用缓存。 在我们的业务场景中,数据库的数据可能被多个来源修改,那么,缓存淘汰策略就是问题。如果修改全部在单一项目进行,那么,可以采用修改时主动淘汰的策略,这样效率最高,显然我们的业务场景不满足。另一种常用的策略是基于超时,但超时时间设多长不好定,短了影响性能,长了影响一致性,每种数据的修改频率也不同,单独设置很繁琐。 考虑到,一次请求的时间非常短(通常几十~几百毫秒),在此过程中,相同的参数几乎100%会返回相同的数据。因此,我们可以实现请求级缓存机制。在请求级缓存机制中,一个缓存的生命周期为,从第一次查询开始,到请求执行结束。 基于这种机制去实现复杂的业务计算需求,可以同时保证代码简洁性(减少共享数据传递)、高效率(只查一次,效率与传递共享数据相同)和数据一致性(极短时间内认为数据不变化)。 参考代码: // 缓存管理器 public class RequestLevelCacheManager implements CacheManager { private final ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); /** * 获取指定请求线程中指定名称的缓存 * @param thread 请求线程 * @param name 缓存名称 * @return 缓存 */ public Cache getCache(Thread thread, String name) { return cacheManager.getCache(thread.threadId() + ":" + name); } /** * 获取当前请求线程中指定名称的缓存 * @param name 缓存名称 * @return 缓存 */ @Override public Cache getCache(String name) { return getCache(Thread.currentThread(), name); } /** * 获取缓存管理器中的所有缓存名称 * @return 缓存名称集合 */ @Override public Collection<String> getCacheNames() { return cacheManager.getCacheNames(); } /** * 清除指定请求线程的缓存 * @param thread 请求线程 */ public void clearCache(Thread thread) { // 缓存管理器中的所有缓存名称 cacheManager.getCacheNames().stream() // 筛选出当前请求线程的所有缓存名称 .filter(name -> name.startsWith(thread.threadId() + ":")) // 获取缓存名称对应的缓存 .map(cacheManager::getCache) // 筛选出非null的缓存 .filter(Objects::nonNull) // 对缓存执行清除 .forEach(Cache::clear); } /** 清除当前请求线程的缓存 */ public void clearCache() { clearCache(Thread.currentThread()); } } // 缓存管理器配置 @Configuration public class CacheConfig { // 将RequestLevelCacheManager设为默认的缓存管理器 @Bean @Primary public RequestLevelCacheManager requestLevelCacheManager() { return new RequestLevelCacheManager(); } } // 像其他类型的缓存一样使用 @Cacheable("cacheName") // 请求拦截器,需要注册到`/**`路径 @Component public class RequestLevelCacheInterceptor implements HandlerInterceptor { @Autowired private RequestLevelCacheManager requestLevelCacheManager; /** 在请求执行结束时,清除请求级缓存 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { requestLevelCacheManager.clearCache(); } }
- backend项目架构设计 - 通用的按关键词搜索的Mapper
搜索是最常用的功能之一,具体到搜索某一种数据,通常有两种形式: 单个搜索框:用户输入关键词(keyword)文本,在数据的多个属性中用关键词筛选。输入简单,但结果可能不够精确。 多个筛选条件:为数据的多个属性分别设置筛选条件,在数据中分别用对应的筛选条件筛选。输入更复杂,但结果更精确。 我们在这里只讨论第一种搜索形式,并将其实现为通用的底层功能,以便在需要时对任意数据快速实现搜索。 前端页面:页面上设置一个单行文本框,用户可输入关键词列表,多个关键词用空格隔开。前端保证关键词列表的每个元素为非空字符串。 后端接口:接收3个参数: keywords:List<String>,关键词列表,可为空列表。如果一条数据包含所有关键词,则该数据在搜索结果中,即多个关键词之间为AND关系。对于一个关键词,数据包含该关键词的定义是,数据的任一指定属性满足指定的与该关键词的关系(例如相等、包含)。 pageNum:int,当前页码。 pageSize:int,每页条数。 后端在Mapper层实现通用的按关键词搜索(SearchByKeywordsMapper接口),并留出扩展性。实体Mapper接口调用SearchByKeywordsMapper接口的方法,并传入以下信息: keywords、pageNum、pageSize。 select语句,即如何查询指定的数据。 属性条件,即哪些属性(可多个),与关键词要满足什么关系(例如相等、包含)。 order by子句,即如何排序。 为了简化实现,我们使用PageHelper分页插件实现自动分页,并支持使用Map+简短标记(如=、like)来简化属性条件的表达。 参考代码: /** * 通用的按关键词搜索的Mapper(面向MySQL数据库) * @param <T> 实体类 */ public interface SearchByKeywordsMapper<T> { /** * 搜索并分页 * @param keywords 搜索关键词列表 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 * @implNote 请在实体Mapper中使用{@code default}方法实现此方法,并在{@code default}方法中调用 * {@link #_search(String, List, Map, String, int, int)}方法实现搜索和分页功能。 */ Page<T> search(List<String> keywords, int pageNum, int pageSize); /** * 搜索并分页 * @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。 * @param keywords 搜索关键词列表 * @param keywordConditions 单个关键词的搜索条件映射表。 * <p>其中,键为列名,例如{@code "e.name"}。</p> * <p>值为条件,支持以下4种形式:<p> * <ol> * <li>{@code "="}:表示等于当前关键词,即<code>"column = #{keyword}"</code>。</li> * <li>{@code "like"}:表示字符串包含当前关键词,即<code>"column like concat('%', #{keyword}, '%')"</code>。</li> * <li>{@code "find_in_set"}:表示使用{@code find_in_set()}函数判断包含,即<code>"find_in_set(#{keyword}, column)"</code>。</li> * <li>自定义条件:不含列名,关键词的占位符为<code>#{keyword}</code>,即<code>"column 自定义条件"</code>。</li> * </ol> * @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 */ default Page<T> _search( String select, List<String> keywords, Map<String, String> keywordConditions, @Nullable String orderBy, int pageNum, int pageSize ) { var keywordConditionsString = keywordConditions.entrySet().stream() .map(entry -> { var column = entry.getKey(); var condition = entry.getValue(); if (condition.equals("=")) { return column + " = #{keyword}"; } else if (condition.equalsIgnoreCase("like")) { return column + " like concat('%', #{keyword}, '%')"; } else if (condition.equalsIgnoreCase("find_in_set")) { return "find_in_set(#{keyword}, " + column + ")"; } else { return column + " " + condition; } }) .collect(Collectors.joining(" or ")); return _search(select, keywords, keywordConditionsString, orderBy, pageNum, pageSize); } /** * 使用PageHelper分页插件执行搜索并分页 * @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。 * @param keywords 搜索关键词列表 * @param keywordConditions 单个关键词的搜索条件,其中关键词的占位符为<code>#{keyword}</code>。 * @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 */ @Select(""" <script> <![CDATA[ ${select} ]]> <where> <foreach item="keyword" collection="keywords" separator="and"> ( <![CDATA[ ${keywordConditions} ]]> ) </foreach> </where> <if test="orderBy != null and orderBy != ''"> order by <![CDATA[ ${orderBy} ]]> </if> </script> """) Page<T> _search( String select, List<String> keywords, String keywordConditions, @Nullable String orderBy, int pageNum, int pageSize ); } // 实体Mapper public interface EntityMapper extends SearchByKeywordsMapper<Entity> { /** * 搜索实体 * @param keywords 搜索关键词列表 * @param pageNum 当前页码 * @param pageSize 每页条数 * @return 分页对象 */ default Page<Entity> search(List<String> keywords, int pageNum, int pageSize) { return _search( "select ...", keywords, Map.of( "column1", "=", "column2", "like" ), "column1, column2 desc", pageNum, pageSize ); } }
- backend项目架构设计 - 全局Bean名称生成器
问题 一个软件项目中经常存在名称相同但意义不同的类,例如实施领域和研发领域都有“项目”这个实体,也就都有ProjectMapper、ProjectService、ProjectController等类。 Spring Boot默认将类名转为小写驼峰形式作为Bean名称,这就导致出现相同的Bean名称,Spring Boot报错。 解决方案 编写GlobalBeanNameGenerator类,对于单例Bean(绝大多数情况),将Bean名称自动设置为类的完全限定名,确保唯一。然后在@SpringBootApplication、@MapperScan中使用GlobalBeanNameGenerator。 public class GlobalBeanNameGenerator extends AnnotationBeanNameGenerator { @Override public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) { // 如果是单例,且能获取到bean的类名 if (definition.isSingleton() && definition.getBeanClassName() != null) { // 返回类的完全限定名作为bean的名称,此时不同包的同名类会生成不同的bean名称 return definition.getBeanClassName(); } // 其他情况,保持原有的生成名称的逻辑 return super.generateBeanName(definition, registry); } } @SpringBootApplication(nameGenerator = GlobalBeanNameGenerator.class) @MapperScan(nameGenerator = GlobalBeanNameGenerator.class)
- backend项目架构设计 - 包结构
通常在Spring Boot项目中,包结构如下: com.company.project entity mapper service controller 实践发现,随着项目越来越复杂,类越来越多,这种组织方式存在明显问题:同一个数据或业务相关的多个类分散在相隔甚远的不同包中,而编写、阅读代码却又是一起的,所以,在IDE中需要反复上下滚动,来找到需要的类,麻烦、效率低、不清晰。 在backend项目中,我们以数据或业务为中心组织包结构,包结构如下: com.company.project domain:领域包 entity/business:实体/业务包 data:数据包 mapper:Mapper包 service:服务包 controller:控制器包 scheduler:调度器包 领域包:表示一个业务领域,例如某个部门专门负责的业务。领域下可以再划分子领域。 实体/业务包:表示一个实体(例如项目、合同),或者一项专门的业务(例如对某数据的分析)。这是代码组织的基本粒度单位,包内的所有代码都是围绕某个实体/业务进行编写的,相关的服务、控制器等类全部集中在一起,方便编写和阅读。实体/业务包中提供数据结构(data包)和操作(service包),这是面向对象思想在包层次上的体现。 数据包:定义数据结构。之所以叫“数据包”而不是“实体包”,是因为数据不仅仅是实体类,还有枚举、表示某种共性的接口、常量等。 Mapper包:存放Mapper接口,实现数据库层面的操作。我们约定,凡是数据库层面的操作,只要能在Mapper接口中实现,就一定在Mapper接口中实现,因为这可以使Mapper层和服务层的职责更清晰,简化服务层。可以通过default方法、private方法、Mapper互相调用等手段来实现这一点。 服务包:存放服务类。服务层是实现业务逻辑的核心,是对外提供操作的核心,其他实体/业务只能通过服务层的方法来使用本实体/业务的操作。通过精心设计服务层方法的粒度,可以实现良好的代码复用。 控制器包:存放控制器类,对前端提供API。 调度器包:存放调度器类,实现定时任务。我们约定,定时任务必须通过调用控制器层来执行,而不能直接调用服务层,这是因为我们经常有手动触发定时任务的需求,手动触发会通过控制器层的API来执行,所以为了避免不一致,故做此约定。换句话说,调度器只是提供了一个定时触发的机制而已,无论是定时触发还是手动触发,执行都是从控制器层开始的。
- backend项目架构设计 - 起步
我们之前有一个Java后端项目,但一直是野蛮生长的状态,简单粗暴地堆叠功能,没有经过良好的设计,没有制定统一的开发规范,每个人的代码都是不同的样子,每个人不会也不敢复用别人的代码,这些问题导致后续的开发、维护越来越艰难、低效。 我现在是公司内部信息化的技术负责人,从长远考虑,我认为有必要通过架构设计来解决这些问题,提升软件质量和团队效率。 经过分析评估,基本技术选型如下: 新起项目。 原因:仔细评估后发现,新的架构设计无法在老项目上实施,必然会遭遇无数兼容性问题,我们开发资源不够,不足以充分解决这些问题,那么必然会导致新旧代码都受影响。新起项目可以甩开历史包袱,旧代码按需逐步迁移即可。 单体项目。 原因:考虑现有的业务量、用户规模和开发资源,单体项目是足够的。微服务等分布式架构,目前不适合,强行上反而会使问题复杂化。 编程语言:Java 21。 原因:现有系统使用Java开发,现有人员擅长Java,因此语言选择Java。Java 21是当前最新LTS版本,有很多易用的新特性,值得拥抱新技术。 开发框架:Spring Boot 3,MyBatis。 原因:业界最常用,熟悉。选用框架的最新版本。 我将新项目的名称定为backend,意在作为以后的统一后端项目。 本项目将首要解决良好设计、开发规范、代码复用等过去的痼疾,提升软件的正确性、可维护性。配合团队管理(统一思想培训、代码评审等),本项目从诞生起就要严格控制代码的准入门槛,并以每一个实际需求为案例,不断迭代、优化架构设计。