普通视图

发现新文章,点击刷新页面。
昨天以前码志

Java|小数据量场景的模糊搜索体验优化

作者 Zhuang Ma
2025年4月23日 00:00

在小数据量场景下,如何优化模糊搜索体验?本文分享一个简单实用的方案,虽然有点“土”,但效果还不错。

场景

假设有一张表 t_course,数据量在三到四位数,字段 name 需要支持模糊搜索。用普通的 LIKE 语句,比如:

SELECT id, name FROM t_course WHERE name LIKE '%2025数学高一下%';

结果却查不到 2025年高一数学下学期。这就很尴尬了,用户体验直接拉胯。

方案探索

1. MySQL 全文索引

首先想到 MySQL 的全文索引,但要支持中文分词得改 ngram_token_size 配置,还得重启数据库。为了不动生产环境配置,果断放弃。

2. Elasticsearch

接着想到 Elasticsearch,但对这么简单的场景来说,未免有点“杀鸡用牛刀”。于是继续寻找更轻量的方案。

3. 自定义分词 + MySQL INSTR

最后想到一个“土办法”:先对用户输入进行分词,再用 MySQL 的 INSTR 函数匹配。简单粗暴,但很实用。

实现

分词工具

一开始用了 jcseg 分词库,写了个工具类:

public class JcSegUtils {
    private static final SegmenterConfig CONFIG = new SegmenterConfig(true);
    private static final ADictionary DIC = DictionaryFactory.createSingletonDictionary(CONFIG);

    public static List<String> segment(String text) throws IOException {
        ISegment seg = ISegment.NLP.factory.create(CONFIG, DIC);
        seg.reset(new StringReader(text));
        IWord word;
        List<String> result = new ArrayList<>();
        while ((word = seg.next()) != null) {
            String wordText = word.getValue();
            if (StringUtils.isNotBlank(wordText)) {
                result.add(wordText);
            }
        }
        return result;
    }
}

本地测试一切正常,但部署到测试环境后,分词结果却变了!比如:

  • 本地:[2025, 数学, 高一, 下]
  • 测试环境:[2025, 数, 学, 高, 1, 下]

原因是 jcseg 在 jar 包中加载默认配置和词库时出问题了。网上的解决方案大多是外置词库,但我懒得折腾,决定自己撸个简易分词工具。

简易分词工具

最终实现如下:

public class WordSegmentationUtils {
    private static final List<String> DICT;
    private static final String COURSE_SEARCH_KEYWORD_LIST = "数学,物理,化学,生物,地理,历史,政治,英语,语文,高中,高一,高二,高三";

    static {
        DICT = new ArrayList<>();
        for (int i = 2018; i <= 2099; i++) {
            DICT.add(String.valueOf(i));
        }
        DICT.addAll(Arrays.asList(COURSE_SEARCH_KEYWORD_LIST.split(",")));
    }

    public static List<String> segment(String text) {
        if (StringUtils.isBlank(text)) {
            return new ArrayList<>();
        }
        List<String> segments = new ArrayList<>();
        segments.add(text);
        for (String word : DICT) {
            segments = segment(segments, word);
        }
        return segments;
    }

    private static List<String> segment(List<String> segments, String word) {
        List<String> newSegments = new ArrayList<>();
        for (String segment : segments) {
            if (segment.contains(word)) {
                newSegments.add(word);
                String[] split = segment.split(word);
                for (String s : split) {
                    if (StringUtils.isNotBlank(s)) {
                        newSegments.add(s.trim());
                    }
                }
            } else {
                newSegments.add(segment);
            }
        }
        return newSegments;
    }
}

这个工具基于一个简单的词典 DICT,按词典中的词对输入文本进行分割。比如:

  • 输入:2025数学高一下
  • 输出:[2025, 数学, 高一, 下]

效果验证

现在,无论用户输入以下哪种形式,都能成功匹配到 2025年高一数学下学期

  • 2025高一数学下
  • 2025 高一 数学
  • 数学高一2025

小结

这个方案虽然简单,但在小数据量场景下,性能和体验都能满足需求,且实现成本低。如果遇到特殊情况,可以通过动态更新词典来解决。

当然,这种“土办法”并不适合复杂场景。如果需求升级,可以再考虑 MySQL 全文索引或 Elasticsearch。

最后,自己一个人负责开发和运维就是任性!如果有团队一起评审,这方案可能早就被否了吧……额,达摩克利斯之剑高悬。

iOS|解决 setBrightness 调节屏幕亮度不生效的问题

作者 Zhuang Ma
2025年2月26日 00:00

在包含视频播放功能的 App 中,一种常见的交互是在播放器界面的左侧上下滑动调节屏幕亮度,右侧上下滑动调节音量。我们的 iOS App 里也是这样设计的,但最近在测试过程中,发现亮度调节不生效了。

摸索之路

代码里面调节亮度的实现是这样的:

- (void)setBrightnessUp {
    if ([UIScreen mainScreen].brightness >=1) {
        return;
    }
    [UIScreen mainScreen].brightness += 0.01;
    // ...
}

- (void)setBrightnessDown {
    if ([UIScreen mainScreen].brightness <=0) {
        return;
    }
    [UIScreen mainScreen].brightness -= 0.01;
    // ...
}

这个实现在较早之前是没有问题的,那我首先想到比较可能是因为系统的更新,对这个 API 做了变更。于是先查阅了 UIKit/UIScreen/brightness 的官方文档,里面只提到了 brightness 属性只在 main screen 上被支持,取值范围是 [0.0, 1.0],以及亮度调节后,直到锁屏后才会失效——即使用户在锁屏之前已经关闭了 App。并没有看到什么值得特别留意的。

然后继续看代码里的 UIScreen.mainScreen,这个属性被标记为:

API_DEPRECATED("Use a UIScreen instance found through context instead: i.e, view.window.windowScene.screen", ios(2.0, API_TO_BE_DEPRECATED), visionos(1.0, API_TO_BE_DEPRECATED))

但当前在我使用的 SDK 18.2 版本中,这个属性应仍可正常使用。

在 Google 和 StackOverflow 找了一圈,大家讨论亮度调节不生效主要集中以下方面:

也没有找到什么能匹配我的场景的解决方案。

加了一些日志,在调节亮度前后分别打印了 brightness 的值,发现它在调用 setBrightness 方法后并没有发生变化,也没有报错和告警,看起来就像是这个方法根本没有被调用一样。

也做了一些其它尝试,比如把调整亮度的代码显式调度到主线程、使用 view.window.windowScene.screen 替代 UIScreen.mainScreen 等,但都没有效果。

无奈之下,我问了 GitHub Copilot 一嘴,它的回答是这样的:

我按它的建议检查了权限,确认了不存在权限问题。

有点绝望之际,看到它提供的代码里调整亮度的粒度是 0.1,而我的代码里是 0.01,于是我尝试将粒度改为 0.1,然后奇迹发生了,亮度调节生效了

这就有点匪夷所思了……于是我又尝试了其它的粒度值,结果如下:

  • 0.01,不生效;
  • 0.02,不生效;
  • 0.03 及以上,生效,但是从输出可以看到,实际调整后的亮度值都是 0.05 的倍数,即 0.05、0.1、0.15、0.2……,而不是 0.03、0.06、0.09、0.12……

我找到安装了以前老版本 App 的一个老平板(iOS 10.3.3),在上面测试了一下,发现在这个版本上,0.01 的调节粒度是可以生效的。

也就是说,在 iOS (10.3.3, 18.2) 之间靠近后者的某个版本上,[UIScreen mainScreen].brightness 的调节粒度发生了变化,由 0.01 变为了 0.05。

至此破案了,顺便吐槽一下,官方文档里对此毫无提及,实在是……略坑。

参考

iOS|记一名 iOS 开发新手的前两次 App 审核经历

作者 Zhuang Ma
2025年2月25日 00:00

说来惭愧,独立支撑公司的软件系统已经一年有余,多数的精力都在开发和迭代 Web 服务与 Android 端,对于 iOS App 则是一直没有更新,遇到相关的 bug 反馈也是能拖就拖——毕竟,大多数情况下找个 workaround 还是不难的。

回过头想想,可能潜意识里一直有点犯怵,觉得 iOS 开发是自己的薄弱环节,所以总想着等有时间,再多学一点相关的东西,准备得更充分、更有自信能处理好了,再去更新。可一直这样下去也不是办法,所以春节前结合一些业务需求,我决定逼自己一把,尽快把 iOS App 更新一下。

面对一个个所谓难题:

  • Objective-C 的语法一阵没用又快忘光了——突击复习了一波;
  • API 的细节不熟悉——看文档,参考老代码里的写法;
  • 有一些变动不确定是否影响兼容性——查文档,问老同事,做测试;
  • ……

然后在这个 AI 大行其道的时代,作为尊贵的 GitHub Copilot Pro 用户,在插件的辅助下光速添加了一个新的小功能,修复了一些 bug 后,我向 App Store Connect 提交了我的第一次版本审核,本以为需要经过漫长的等待,结果……

事情出乎意料地顺利,几个小时就通过了,这玩意也有新手保护期?

满怀得意我心欢喜,于是一鼓作气把囤积已久的几个 feature 给做了,然后兴冲冲地提交了第二次版本审核,结果……

几个小时后第一次被驳回,原因是:

Guideline 3.1.1 - Business - Payments - In-App Purchase

We found in our review that your app or its metadata provides access to mechanisms other than in-app purchase for purchases or subscriptions to be used in the app, which does not comply with the App Review Guidelines. Specifically:


- Your app's binary includes the following call-to-action and/or URL that directs users to external mechanisms for purchases or subscriptions to be used in the app:


User have to contact customer service to puchase credits.

看截图我查到了这是几年前参考一个大厂 App 实现的效果,在用户余额不足时,弹出一个提示框,上面有两个按钮,一个点击后展示了客服的联系方式,一个是「取消」,点击后跳转到充值页面。

我以为这里面的主要问题点是没有明确的「去充值」入口,导致审核人员以为用户无法直接充值,必须联系客服,于是我添加了一个「去充值」的按钮,将「取消」按钮的动作改为隐藏提示框,然后再次提交审核,结果几个小时后又被驳回了,原因仍然是:

- Your app's "xxx" page includes the following call-to-action and/or URL that directs users to external mechanisms for purchases or subscriptions to be used in the app

不过发过来的消息里还有这样一段:

Bug Fix Submissions

The issues we've identified below are eligible to be resolved on your next update. If this submission includes bug fixes and you'd like to have it approved at this time, reply to this message and let us know. You do not need to resubmit your app for us to proceed.


Alternatively, if you'd like to resolve these issues now, please review the details, make the appropriate changes, and resubmit.

其实,这时候我只要回复个消息,说我这次提交包含了一些 bug 修复,希望能通过审核,下次我再修复这个问题,就妥了……

但我当时脑子里不知道咋想的,可能是觉得这么个小问题,这次解决掉算了,然后就又改了一版,将那个跳转到客服联系方式的按钮去掉,又提交了一版,这时候我以为这把稳了,就收工等消息了。

回家正刷着沙雕视频呢,弹出来消息,又被拒了,这回是什么原因呢……

Guideline 2.1 - Performance - App Completeness
Issue Description


The app exhibited one or more bugs that would negatively impact App Store users.


Bug description: unable to load "xxx"

我人都懵了,同时懊恼万分,真不应该装这个逼,就该回个消息,让审核员先通过再说。

话说回来,这个 xxx 功能已经在线上跑了几年了,最近也没改过,最后思来想去,怀疑可能是审核员当时遇上了什么网络波动之类的,导致没加载出来。

没辙,我只好在一些设备上复测了一下,保存了一些该功能正常使用的截图,然后回复给审核员,表明这个功能经我多次测试是正常的,已经上线运行了几年且最近没有修改过,希望审核员能再次确认,如果可以的话帮忙通过审核。

然后这回真的是漫长的等待,等了两天多,度过了一个忐忑的周末后,终于在周一一大早盼来了好消息。这一个版本的审核历时五天,经过了三轮被拒,总算是磕磕绊绊地通过了:

以上就是我这个 iOS 开发新手的前两次 App 审核经历,总结一下,主要有以下几点:

  • 充分测试,保证功能的完整和稳定;
  • App 审核 的相关文档保持关注,避免一些容易被驳回的问题;
  • 有问题及时回复审核员,解释清楚问题所在,提供相关的截图、视频等证据。

总的来讲,相比 Android App 需要提交到各家应用市场,然后面临不同的审核标准和结果,iOS App 的审核体验相对还是不错的,毕竟只用面对唯一的渠道和标准。

Mac mini 通过键盘连接蓝牙鼠标

作者 Zhuang Ma
2025年1月9日 00:00

前一阵发生过两次 Mac mini 与蓝牙鼠标断连的情况,都是通过借用别人的有线鼠标来重新连接的,终究不方便。

后来就想着,能不能通过键盘来连接蓝牙鼠标呢?摸索了一番,找到了方法,在此记录一下。

先上操作演示:

前置知识

看着上面的操作,是不是感觉 so easy?但实际上,我在操作过程中,一开始就遇到一个问题——焦点无法移动到设置面板右侧的按钮上。

这时我们要先了解一个关键的设置开关,以及它对应的快捷键:

该开关默认关闭,切换开关的默认快捷键是 Ctrl + F7

通过键盘连接蓝牙鼠标的方法

  1. 通过键盘操作,打开系统设置:

    • 按下 Cmd + 空格,调出 Spotlight 搜索框;

    • 输入 系统设置,回车。

  2. 通过键盘上下键,定位到 蓝牙

  3. 操作蓝牙鼠标,使其进入配对模式;(一般是长按鼠标底部的配对键)

  4. 连续按下 Tab 键,定位到蓝牙鼠标对应设备的 连接 按钮;

    注意: 这里有可能发现,按 Tab 键焦点无法移动到设置面板右侧,这时就需用到我们前面提到的设置开关了,按下 Ctrl + F7,开启键盘导航功能,再按 Tab 键就可以移动焦点了。

  5. 按下 Space 键,连接蓝牙鼠标。

大功告成!

其它思路

除上了述方法,我搜索的过程中还看到有一些其它思路,比如:

  • Cmd + Option + F5 打开辅助功能里的一些开关,然后通过键盘模拟鼠标操作;
  • 喊 Siri 帮你重启蓝牙;

可以按需尝试和使用。

Java|如何用一个统一结构接收成员名称不固定的数据

作者 Zhuang Ma
2024年11月29日 00:00

本文介绍了一种 Java 中如何用一个统一结构接收成员名称不固定的数据的方法。

背景

最近在做企业微信的内部应用开发,遇到了一个小问题:企业微信的不同接口,返回的数据的结构不完全一样。

比如,获取部门列表接口返回的数据结构是这样的:

{
    "errcode": 0,
    "errmsg": "ok",
    "department": [
        {
            "id": 2,
            "name": "广州研发中心",
            "name_en": "RDGZ",
            "department_leader":["zhangsan","lisi"],
            "parentid": 1,
            "order": 10
        }
    ]
}

而获取部门成员接口返回的数据结构是这样的:

{
    "errcode": 0,
    "errmsg": "ok",
    "userlist": [
        {
            "userid": "zhangsan",
            "name": "张三",
            "department": [1, 2],
            "open_userid": "xxxxxx"
        }
    ]
}

就是说,不同接口的返回框架是一样的,都是 errcode + errmsg + 数据部分,但数据部分的成员名称不一样,比如上面的 departmentuserlist

我不知道为什么这样设计,从 Java 开发者的习惯来讲,如果由我来设计,我会尽量保持接口返回的数据结构的一致性,比如数据部分都用 data 来表示,这样在序列化、反序列化的时候可以用一个统一的泛型结构来进行。

当然这可能是企微内部的开发语言或习惯的差异,或者其它原因,这里也无法深究,只谈如何应对。

分析

遇到这个问题后,第一反应是用 JSON 结构来接收,然后不同接口的数据部分用不同的 key 来读取。可以实现,但总觉得不够优雅。

然后想到 GitHub 上应该有不少开源的企微开发的封装库,去看看它们的实现,说不定会有更好的方案,最终果然有收获。

主要看了两个库:

  • https://github.com/binarywang/WxJava
  • https://github.com/NotFound403/wecom-sdk

前者 WxJava 知名度更高,包含的东西也更多,包含微信、企微的各种开发包的封装。它这块的实现是用我们前面提到的方法,用 JSON 结构来接收,然后不同接口的数据用不同的 key 来读取。

后者 wecom-sdk 是企微的开发包。它这块的实现是用了一个统一的泛型结构来接收数据。

以下分别截取两个库的两个部门管理相关接口的封装代码:

WxJava 版:

https://github.com/binarywang/WxJava/blob/develop/weixin-java-cp/src/main/java/me/chanjar/weixin/cp/api/impl/WxCpDepartmentServiceImpl.java

@Override
public List<WxCpDepart> list(Long id) throws WxErrorException {
    String url = this.mainService.getWxCpConfigStorage().getApiUrl(DEPARTMENT_LIST);
    if (id != null) {
      url += "?id=" + id;
    }

    String responseContent = this.mainService.get(url, null);
    JsonObject tmpJsonObject = GsonParser.parse(responseContent);
    return WxCpGsonBuilder.create()
      .fromJson(tmpJsonObject.get("department"),
        new TypeToken<List<WxCpDepart>>() {
        }.getType()
      );
  }

@Override
public List<WxCpDepart> simpleList(Long id) throws WxErrorException {
    String url = this.mainService.getWxCpConfigStorage().getApiUrl(DEPARTMENT_SIMPLE_LIST);
    if (id != null) {
      url += "?id=" + id;
    }

    String responseContent = this.mainService.get(url, null);
    JsonObject tmpJsonObject = GsonParser.parse(responseContent);
    return WxCpGsonBuilder.create()
      .fromJson(tmpJsonObject.get("department_id"),
        new TypeToken<List<WxCpDepart>>() {
        }.getType()
      );
  }
}

wecom-sdk 版:

https://github.com/NotFound403/wecom-sdk/blob/release/wecom-sdk/src/main/java/cn/felord/api/DepartmentApi.java

@GET("department/list")
GenericResponse<List<DeptInfo>> deptList(@Query("id") long departmentId) throws WeComException;

@GET("department/simplelist")
GenericResponse<List<DeptSimpleInfo>> getSimpleList(@Query("id") long departmentId) throws WeComException;

抛开 wecom-sdk 版引入了 Retrofit2 库的支持导致的代码量锐减,在返回数据的反序列化上,我也更倾向于 wecom-sdk 版的实现。

实现

那接下来我们直接参照 wecom-sdk 里的实现方式,写一个泛型类,就可以用来接收企微的不同接口返回的数据了:

@Data
public class WxWorkResponse<T> {

    @JsonProperty("errmsg")
    private String errMsg;

    @JsonProperty("errcode")
    private Integer errCode;

    @JsonAlias({
            "department",
            "userlist"
    })
    private T data;
}

这里面起到关键作用的是 Jackson 库里的 @JsonAlias 注解。它的官方文档是这样介绍的:

Annotation that can be used to define one or more alternative names for a property, accepted during deserialization as alternative to the official name. Alias information is also exposed during POJO introspection, but has no effect during serialization where primary name is always used.
Examples:
  public class Info {
    @JsonAlias({ "n", "Name" })
    public String name;
  }
  
NOTE: Order of alias declaration has no effect. All properties are assigned in the order they come from incoming JSON document. If same property is assigned more than once with different value, later will remain. For example, deserializing
   public class Person {
      @JsonAlias({ "name", "fullName" })
      public String name;
   }
   
from
   { "fullName": "Faster Jackson", "name": "Jackson" }
   
will have value "Jackson".
Also, can be used with enums where incoming JSON properties may not match the defined enum values. For instance, if you have an enum called Size with values SMALL, MEDIUM, and LARGE, you can use this annotation to define alternate values for each enum value. This way, the deserialization process can map the incoming JSON values to the correct enum values.
Sample implementation:
public enum Size {
       @JsonAlias({ "small", "s", "S" })
       SMALL,
  
       @JsonAlias({ "medium", "m", "M" })
       MEDIUM,
  
       @JsonAlias({ "large", "l", "L" })
       LARGE
   }
During deserialization, any of these JSON structures will be valid and correctly mapped to the MEDIUM enum value: {"size": "m"}, {"size": "medium"}, or {"size": "M"}.

回到我们的例子,除了 departmentuserlist 之外还用到其它的 key,可以继续在 @JsonAlias 注解里添加。

这样,对不同的接口的封装,我们反序列化后统一 getData() 就可以获取到数据部分了,使用时不用再去操心数据部分的 key 是什么。

小结

有人总问,阅读别人源码的意义是什么,这也许就可以作为一个小例子吧。

为什么 GitHub Pages 的文章标题不能以 @ 开头?

作者 Zhuang Ma
2024年10月11日 00:00

本文记录了一个 GitHub Pages 博客网页上文章标题以 @ 开头导致的问题,并分析了原因,提供了解决方法。

TL;NR:因为 YAML 的语法规则,GitHub Pages 的文章标题不能直接以 ,[]{}#&*!|>'"%@`-?:加空格 开头。可以用引号将标题括起来,或者修改标题,将这些字符不放在开头。

接到问题

接网友提问:

有一篇文章在 GitHub Pages 博客网页上不显示,初步排查可能与 title 有关——替换成其它文章的 title 可以正常显示,并附上了原始文件的头部:

---
layout: post
title: @EnableconfigurationProperties注解使用方式与作用
categories: [Java]

复现

乍一看看不出什么问题,我在本地启动 Jekyll 预览,以本文件作为测试,也复现了该现象。

使用 title「为什么 GitHub Pages 的文章标题不能以 @ 开头?」时,正常

使用 title「@EnableconfigurationProperties注解使用方式与作用」时,文章标题与摘要显示空白

并可以在控制台看到如下错误:

Error: YAML Exception reading /Users/mazhuang/github/mzlogin.github.io/_posts/2024-10-11-why-github-pages-post-title-cannot-start-with.md: (<unknown>): found character that cannot start any token while scanning for the next token at line 3 column 8

分析

报错信息里提到是 YAML Exception——Jekyll 的文章头部是 YAML Front Matter,是 Jekyll 用来定义文章元数据的部分。报错提示 line 3 column 8,即 title 的第一个字符 @,is character that cannot start any token。

根据这个信息,其实已经可以想办法规避这个问题——将 title 里的 @ 去掉,或者换个位置,经验证可以正常显示了。

继续深究一下,为什么 YAML 里的 title 不能以 @ 开头呢?

然后找到了如下链接:

提炼一下要点:

  • YAML 里有一些指示字符,具有特殊语义,如 -?:,[]{}#&*!|>'"%@`
  • 这些特殊(或保留)字符不能用作不带引号的标量的第一个字符:,[]{}#&*!|>'"%@`
  • ?:- 后面如果跟着非空格字符,可以放在字符串的开头,但 YAML 处理器的不同实现可能带来不同行为,稳妥起见最好也用引号括起来。

解决方法

  • 将 title 用引号括起来,如 title: "@EnableconfigurationProperties注解使用方式与作用";(推荐)
  • 修改 title ,将上述不能直接放在开头的字符换个位置。

Java|让 JUnit4 测试类自动注入 logger 和被测 Service

作者 Zhuang Ma
2024年9月25日 00:00

本文介绍如何通过自定义 IDEA 的 JUnit4 Test Class 模板,实现生成测试类时自动注入 logger 和被测 Service。

背景

在 IntelliJ IDEA 中,通过快捷键可以快速生成 JUnit4 测试类,但是生成测试类以后,总是需要手动添加 logger 和被测 Service 的注入。虽然这是一个很小的「重复动作」,但程序员还是不能忍(其实已经忍了很多年了)。

需求

以给如下简单的 Service 生成测试类为例:

package com.test.data.user.service;

import com.test.common.base.BaseService;
import com.test.data.user.entity.UserSource;

/**
 * @author mazhuang
 */
public interface UserSourceService extends BaseService<UserSource> {

    /**
     * 记录用户来源
     * @param userId -
     * @param threadId -
     */
    void recordUserSource(Long userId, Long threadId);
}

command + n 调出 Generate 菜单,然后选择 Test,配置测试类的名称、基类和包:

默认生成的测试类如下:

package com.test.data.user.service;

import static org.junit.Assert.*;

import com.test.BaseTests;
import org.junit.Test;

/**
 * @author mazhuang
 */
public class UserSourceServiceTest extends BaseTests {
    @Test
    public void recordUserSource() {
    }
}

然而在写测试用例的过程中,总是需要用到 logger 和 Service,所以期望中的测试类默认长这样:

package com.test.data.user.service;

import static org.junit.Assert.*;

import com.test.BaseTests;
import org.junit.Test;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * @author mazhuang
 */
@Slf4j
public class UserSourceServiceTest extends BaseTests {

    @Autowired
    private UserSourceService userSourceService;

    @Test
    public void recordUserSource() {
    }
}

方案与实现

经过一番 search,发现 IDEA 的 Preference - Editor - File and Code Templates 的 Code 里有一个 JUnit4 Test Class,可以自定义生成 JUnit4 测试类的模板。

这个模板原始内容是这样的:

import static org.junit.Assert.*;
#parse("File Header.java")
public class ${NAME} {
  ${BODY}
}

基于我们的需求,将其修改为以下内容即可:

#set( $LastDotIndex = $CLASS_NAME.lastIndexOf(".") + 1 )
#set( $CamelCaseName = "$CLASS_NAME.substring($LastDotIndex)" )
#set( $CamelCaseName = "$CamelCaseName.substring(0, 1).toLowerCase()$CamelCaseName.substring(1)")

import static org.junit.Assert.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
#parse("File Header.java")
@Slf4j
public class ${NAME} {

    @Autowired
    private ${CLASS_NAME} ${CamelCaseName};
  ${BODY}
}

其中,${CLASS_NAME} 是被测试类的全限定名,${CamelCaseName} 是根据 ${CLASS_NAME} 生成的被测试类的驼峰命名。

至此,经过一点微小的努力,我们实现了一个小小的自动化,工作效率又提高了一点点,程序员又开心了一点点。

小结

察觉到重复动作,并消除——也许可以称之为「偷懒」,这是程序员的日常小乐趣,也是 人类进步的动力 吧。

文中完整脚本已上传至 GitHub,仓库地址:https://github.com/mzlogin/code-generator ,以后如果有更新,或者新的代码生成脚本,也会放在这个仓库里。

Java|在 IDEA 里自动生成 MyBatis 模板代码

作者 Zhuang Ma
2024年9月24日 00:00

背景

基于 MyBatis 开发的项目,新增数据库表以后,总是需要编写对应的 Entity、Mapper 和 Service 等等 Class 的代码,这些都是重复的工作,我们可以想一些办法来自动生成这些代码。

方案

一种可选的方案是使用 MyBatis Generator,官方支持,常见需求一般也都能满足。但是它的配置文件比较繁琐,如果有一些项目相关的个性化需求,不一定很好处理。

这里介绍另外一种我觉得更为简便灵活的方法。

近几年版本的 IDEA 里已经自带了 Database Tools and SQL 插件,可以连接数据库进行常用的操作,并且,它还自带了数据库表对应 POJO 类的代码生成器:在 Database 面板里配置好数据源以后,右键表名,依次选择 Scripted Extensions、Generate POJOs.groovy,选择生成路径后,即可生成对应的 Entity 类。

既然能够生成 Entity,那么我们可以基于它进行修改,让它一次性生成我们需要的 Entity、Mapper 和 Service。

需求

基于项目情况,我们对生成的代码有如下要求:

  1. Entity 需要继承指定基类,数据库表的公共字段放在基类里;
  2. Mapper、Service 和 ServiceImpl 分别需要实现指定的类继承关系;
  3. Entity、Mapper 和 Service 需要自动放在对应的子包下。

以 t_promotion_channel 表为例,指定该表和对应的代码目录之后,生成的目录结构如下:

.
├── entity
│   └── PromotionChannel.java
├── mapper
│   └── PromotionChannelMapper.java
└── service
    ├── PromotionChannelService.java
    └── impl
        └── PromotionChannelServiceImpl.java

需要生成的代码如下:

entity/PromotionChannel.java

package com.test.data.promotion.entity;

import com.test.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.persistence.Table;

/**
 * @author mazhuang
 */
@EqualsAndHashCode(callSuper = true)
@Data
@Table(name = "t_promotion_channel")
public class PromotionChannel extends BaseEntity {

    private static final long serialVersionUID = 5495175453870776988L;
    /**
     * 用户ID
     */
    private Long fkUserId;

    /**
     * 渠道名称
     */
    private String channelName;

}

mapper/PromotionChannelMapper.java

package com.test.data.promotion.mapper;

import com.test.common.base.BaseMapper;
import com.test.data.promotion.entity.PromotionChannel;

/**
 * @author mazhuang
 */
public interface PromotionChannelMapper extends BaseMapper<PromotionChannel> {

}

service/PromotionChannelService.java

package com.test.data.promotion.service;

import com.test.common.base.BaseService;
import com.test.data.promotion.entity.PromotionChannel;

/**
 * @author mazhuang
 */
public interface PromotionChannelService extends BaseService<PromotionChannel> {

}

service/impl/PromotionChannelServiceImpl.java

package com.test.data.promotion.service.impl;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import com.test.common.base.BaseServiceImpl;
import com.test.data.promotion.entity.PromotionChannel;
import com.test.data.promotion.mapper.PromotionChannelMapper;
import com.test.data.promotion.service.PromotionChannelService;

/**
 * @author mazhuang
 */
@Slf4j
@Service
public class PromotionChannelServiceImpl extends BaseServiceImpl<PromotionChannelMapper, PromotionChannel> implements PromotionChannelService {

}

实现

右键一个数据库表,依次选择 Scripted Extensions、Go to Scripts Directory,进入生成的脚本目录,找到 Generate POJOs.groovy,复制一份,重命名为 Generate MyBatis Code.groovy,然后修改内容如下:

import com.intellij.database.model.DasTable
import com.intellij.database.util.Case
import com.intellij.database.util.DasUtil

/*
 * Available context bindings:
 *   SELECTION   Iterable<DasObject>
 *   PROJECT     project
 *   FILES       files helper
 */

typeMapping = [
        (~/(?i)int/)                      : "Long",
        (~/(?i)float|double|decimal|real/): "BigDecimal",
        (~/(?i)datetime|timestamp/)       : "java.util.Date",
        (~/(?i)date/)                     : "java.sql.Date",
        (~/(?i)time/)                     : "java.sql.Time",
        (~/(?i)/)                         : "String"
]

FILES.chooseDirectoryAndSave("Choose directory", "Choose where to store generated files") { dir ->
    SELECTION.filter { it instanceof DasTable }.each { generate(it, dir) }
}

def generate(table, dir) {
    def className = javaName(table.getName().replaceFirst('t_', ''), true)
    def fields = calcFields(table)

    dirPath = dir.getAbsolutePath()
    packageName = calcPackageName(dirPath)

    // Generate POJO
    new File(dirPath + File.separator + "entity", className + ".java").withPrintWriter("utf-8") { out -> generateEntity(out, table.getName(), className, fields, packageName) }

    // Generate Mapper
    new File(dirPath + File.separator + "mapper", className + "Mapper.java").withPrintWriter("utf-8") { out -> generateMapper(out, className, packageName) }

    // Generate Service
    new File(dirPath + File.separator + "service", className + "Service.java").withPrintWriter("utf-8") { out -> generateService(out, className, packageName) }

    // Generate ServiceImpl
    new File(dirPath + File.separator + "service" + File.separator + "impl", className + "ServiceImpl.java").withPrintWriter("utf-8") { out -> generateServiceImpl(out, className, packageName) }
}

static def generateEntity(out, tableName, className, fields, packageName) {
    out.println "package $packageName" + ".entity;"
    out.println ""
    out.println "import com.test.common.base.BaseEntity;"
    out.println "import lombok.Data;"
    out.println "import lombok.EqualsAndHashCode;"
    out.println "import javax.persistence.Table;"
    out.println ""
    out.println "/**\n * @author mazhuang\n */"
    out.println "@EqualsAndHashCode(callSuper = true)"
    out.println "@Data"
    out.println "@Table(name = \"$tableName\")"
    out.println "public class $className extends BaseEntity {"
    out.println ""

    def baseEntityFields = ['pkid', 'addedBy', 'addedTime', 'lastModifiedBy', 'lastModifiedTime', 'valid']
    fields.each() {
        if (baseEntityFields.contains(it.name)) {
            return
        }
        if (it.annos != "") out.println "  ${it.annos}"
        if (it.comment != null) out.println "    /**\n     * ${it.comment}\n     */"
        out.println "    private ${it.type} ${it.name};\n"
    }

    out.println "}"
}

static def generateMapper(out, className, packageName) {
    out.println "package $packageName" + ".mapper;"
    out.println ""

    out.println "import com.test.common.base.BaseMapper;"
    out.println "import $packageName" + ".entity.$className;"
    out.println ""

    out.println "/**\n * @author mazhuang\n */"
    out.println "public interface $className" + "Mapper extends BaseMapper<$className> {"
    out.println ""
    out.println "}"
}

static def generateService(out, className, packageName) {
    out.println "package $packageName" + ".service;"
    out.println ""

    out.println "import com.test.common.base.BaseService;"
    out.println "import $packageName" + ".entity.$className;"
    out.println ""

    out.println "/**\n * @author mazhuang\n */"
    out.println "public interface $className" + "Service extends BaseService<$className> {"
    out.println ""
    out.println "}"
}

static def generateServiceImpl(out, className, packageName) {
    out.println "package $packageName" + ".service.impl;"
    out.println ""

    out.println "import lombok.extern.slf4j.Slf4j;"
    out.println "import org.springframework.stereotype.Service;"
    out.println "import com.test.common.base.BaseServiceImpl;"
    out.println "import $packageName" + ".entity.$className;"
    out.println "import $packageName" + ".mapper.$className" + "Mapper;"
    out.println "import $packageName" + ".service.$className" + "Service;"
    out.println ""

    out.println "/**\n * @author mazhuang\n */"
    out.println "@Slf4j"
    out.println "@Service"
    out.println "public class $className" + "ServiceImpl extends BaseServiceImpl<$className" + "Mapper, $className> implements $className" + "Service {"
    out.println ""
    out.println "}"
}

def calcFields(table) {
    DasUtil.getColumns(table).reduce([]) {
        fields, col ->
            def spec = Case.LOWER.apply(col.getDataType().getSpecification())
            def typeStr = typeMapping.find { p, t -> p.matcher(spec).find() }.value
            fields += [[
                               name   : javaName(col.getName(), false),
                               type   : typeStr,
                               comment: col.getComment(), // 注释
                               default: col.getDefault(), // 默认值
                               annos  : ""]]

    }
}

static def calcPackageName(dirPath) {
    def startPos = dirPath.indexOf('com')
    return dirPath.substring(startPos).replaceAll(File.separator, ".")
}

def javaName(str, capitalize) {
    def s = com.intellij.psi.codeStyle.NameUtil.splitNameIntoWords(str)
            .collect { Case.LOWER.apply(it).capitalize() }
            .join("")
            .replaceAll(/[^\p{javaJavaIdentifierPart}[_]]/, "_")
    capitalize || s.length() == 1 ? s : Case.LOWER.apply(s[0]) + s[1..-1]
}

大功告成,现在右键一个数据库表,依次选择 Scripted Extensions、Generate MyBatis Code.groovy,在弹出的目录选择框里选择想要放置代码的目录,即可生成期望的模板代码了。

后续如果有一些个性化的代码生成需求,可以根据实际情况修改、新增脚本来完成。

其它

本文代码生成器脚本已上传至 GitHub,仓库地址:https://github.com/mzlogin/code-generator,以后如果有更新,或者新的代码生成脚本,也会放在这个仓库里。

Java|PageHelper 怎么自作主张帮我分页?

作者 Zhuang Ma
2024年5月20日 00:00

开局上来,我们先看看问题场景的示例代码:

public Page<Xxx> queryXxxList(XxxPageReq req) {

    // some code here

    // 查询一,得到一个结果集,作为查询二的条件
    List<Long> fkIdList = xxxMapper.queryFkIdList(req);
    req.setFkIdList(idList);

    PageHelper.startPage(req.getPageNum(), req.getPageSize());

    // 查询二
    List<Xxx> data = xxxMapper.queryXxxList(req);

    // some code here

}

预期 的逻辑是:查询一不分页,得到一个结果集,作为查询二的条件,查询二分页。

实际 的现象是:查询一被自动添加了 limit,最多只能查询到 10 条数据(示例 req 里的 pageSize 传的 10),导致查询二的查询条件不正确。

分析

初遇到这个问题时,一脸黑人问号,冷静下来后,分析了以下几种可能性,但都一一排除了。

  • 调用当前方法的线程里,已经有其它地方先调用了 PageHelper.startPage(),导致当前方法里的查询一也被分页了;
  • 调用当前方法的线程,上一次调度时设置的分页参数没有被清理;
  • 无厘头的猜想:当前方法是不是在一个大的事务里,而 PageHelper 有什么特殊处理,导致一个事务里的查询都会被分页?

最后通过在 PageInterceptor 里下断点发现了问题所在:

@Override
public Object intercept(Invocation invocation) throws Throwable {
    // some code here 

    //调用方法判断是否需要进行分页,如果不需要,直接返回结果
    if (!dialect.skip(ms, parameter, rowBounds)) {
        // 自动 count 和 分页查询处理
    } else {
        // 跳过 count 和 分页查询处理
    }
    // some code here
}

单步跟进 dialect.skip 方法,关键逻辑在 PageParams.getPage 方法里面:

public Page getPage(Object parameterObject, RowBounds rowBounds) {
    Page page = PageHelper.getLocalPage();
    if (page == null) {
        if (rowBounds != RowBounds.DEFAULT) {
            if (offsetAsPageNum) {
                page = new Page(rowBounds.getOffset(), rowBounds.getLimit(), rowBoundsWithCount);
            } else {
                page = new Page(new int[]{rowBounds.getOffset(), rowBounds.getLimit()}, rowBoundsWithCount);
                //offsetAsPageNum=false的时候,由于PageNum问题,不能使用reasonable,这里会强制为false
                page.setReasonable(false);
            }
        } else if(supportMethodsArguments){
            // 注意这里,我们的查询一进了这个分支
            try {
                page = PageObjectUtil.getPageFromObject(parameterObject, false);
            } catch (Exception e) {
                return null;
            }
        }
        if(page == null){
            return null;
        }
        PageHelper.setLocalPage(page);
    }
    // some code here
}

可以看到,除了显式地提前调用 PageHelper.startPage、传递 rowBounds 参数进行分页外,还有一个 else if(supportMethodsArguments) 的分支,会从传递给查询的参数里尝试读取 pageNumpageSize 字段的值作为分页参数。

随后我查阅了 PageHelper 的官方文档,果然找到了相关的说明:

supportMethodsArguments:支持通过 Mapper 接口参数来传递分页参数,默认值 false,分页插件会从查询方法的参数值中,自动根据上面 params 配置的字段中取值,查找到合适的值时就会自动分页。 使用方法可以参考测试代码中的 com.github.pagehelper.test.basic 包下的 ArgumentsMapTest 和 ArgumentsObjTest。

https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md

那么,为什么我们项目里的 supportMethodsArguments 会为 true 呢?在代码里没有搜索到,最终在 Apollo 配置中心找到了 pagehelper.supportMethodsArguments = true

破案了。

解决

找到问题就好解决了。因为将 pagehelper.supportMethodsArguments = true 这个配置去掉影响太大不可控,所以此处只是将查询一的参数去掉分页字段即可。

修改后:

public Page<Xxx> queryXxxList(XxxPageReq req) {

    // some code here

    // 查询一,得到一个结果集,作为查询二的条件
    // XxxNoPageReq 与 XxxPageReq 里的字段一样,除了没有 pageSize 和 pageNum
    XxxNoPageReq noPageReq = new XxxNoPageReq();
    BeanUtils.copyProperties(req, noPageReq);
    List<Long> fkIdList = xxxMapper.queryFkIdList(noPageReq);

    // some code here

}

小结

修完老代码里的这个问题,我无奈地笑了。真是前人挖坑,后人被坑啊……

开个玩笑。

这严格来说,其实并不能算一个问题,包括解决它需要走的那些弯路,都只能归咎于对 PageHelper 的用法,以及项目的配置,了解不完全。

码途漫漫,上下求索。

Android|修复阿里云播放器下载不回调的问题

作者 Zhuang Ma
2024年5月9日 00:00

最近在升级 Android 项目里的阿里云播放器 SDK 版本,其中很多相关逻辑是基于阿里云提供的 Demo 来更新的。修改完自测时,发现下载器的回调接口偶现不回调的问题。本文简要记录解决过程。

问题描述

首先来看有问题的代码,Demo 里下载相关的有这么一段:

// AliyunDownloadManager.java

public void prepareDownload(final VidAuth vidAuth) {
    // some code here
    final AliMediaDownloader downloader = AliDownloaderFactory.create(mContext);
    downloader.setOnPrepareListener(new AliMediaDownloader.OnPreparedListener() {
            @Override
            public void onPrepared(MediaInfo mediaInfo) {
                // some code here
            }
    });
    setErrorListener(downloader, null);
    // some code here
}

private void setErrorListener(final AliMediaDownloader jniDownloader, final AliyunDownloadMediaInfo aliyunDownloadMediaInfo) {
    // some code here
    jniDownloader.setOnErrorListener(new AliMediaDownloader.OnErrorListener() {
        @Override
        public void onError(ErrorInfo errorInfo) {
            // some code here
        }
    });
}

调用这个方法后,正常情况下 onPrepared 和 onError 必被调用到其一,但现实是偶尔会出现两个都没被回调的情况。

那种感觉就像,你约好了朋友一起吃饭,结果他突然失联了,打电话不接,发消息不回,然后你就一个人在那里等着。这种事情在生活里不能忍,在代码里能忍?

排查和修改

通过一番艰苦的排查,久久没有思路,只好闷闷不乐地下班回家了。晚上洗澡的时候,突然想到,会不会是 downloader 被 GC 了?赶紧拿小本本记下思路,第二天一早翻出这块代码一看,果然如此。

downloader 是在方法内部创建的局部变量,方法执行完毕后,downloader 就会被释放,如果此时发生 GC,就会回收它对应的对象。到了回调时机,发现 downloader 对应的对象已经被回收了,回调也就无从谈起了

那要打破这个局面,就需要将 downloader 的引用保持住,在必要的回调发生以后再释放。比如我们可以想到,这段代码所在类是一个单例,那么在它里面声明一个 List,将 downloader 放进去,等回调结束后再移除。这样就能保证 downloader 在回调发生时还没有被回收。

修改后的代码类似这样:

// AliyunDownloadManager.java
private List<AliMediaDownloader> mJniDownloadLists = new ArrayList<>();

public void prepareDownload(final VidAuth vidAuth) {
    // some code here
    final AliMediaDownloader downloader = AliDownloaderFactory.create(mContext);
    downloader.setOnPrepareListener(new AliMediaDownloader.OnPreparedListener() {
            @Override
            public void onPrepared(MediaInfo mediaInfo) {
                // some code here
                // 增加了这一行
                mJniDownloadLists.remove(downloader);
            }
    });
    setErrorListener(downloader, null);
    // some code here
    // 增加了这一行
    mJniDownloadLists.add(downloader);
}

private void setErrorListener(final AliMediaDownloader jniDownloader, final AliyunDownloadMediaInfo aliyunDownloadMediaInfo) {
    // some code here
    jniDownloader.setOnErrorListener(new AliMediaDownloader.OnErrorListener() {
        @Override
        public void onError(ErrorInfo errorInfo) {
            // some code here
            // 增加了这一行
            mJniDownloadLists.remove(jniDownloader);
        }
    });
}

在尝试做以上修改时,发现了一个 彩蛋

阿里云的官方 Demo 里,其实已经声明了 mJniDownloadLists,且有注释 保存Downloader防止循环创建时,导致内存不足被回收,无法回调的问题,并且在其它类型的 prepareDownload 里也做了类似的处理,但不知道为什么在 VidAuth 类型参数的 prepareDownload 里没有,可能是漏掉了。

小结

在 Android / Java 项目里,类似的场景应该并不少见。虽然 GC 带来了很多便利,但在实际编码时,我们也需要注意对象的生命周期管理,该存活的存活,该释放的释放,避免因为 GC 导致的问题。

Android|记一个导致 logback 无法输出日志的问题

作者 Zhuang Ma
2024年4月22日 00:00

之前写过《Android|集成 slf4j + logback 作为日志框架》,最近在给手上的另外一个 Android 项目添加 logback 日志框架时,遇到一个导致无法输出日志到文件的问题,也耽误了不少时间,这里记录一下。

现象

代码里通过 @Slf4j 注解注入 logger,正常 log.info(xxx),但是程序启动后,发现没有生成日志文件。

排查

检查配置文件

有前一个项目的成功经验,我直接复制了之前项目的配置文件,只是将日志存储文件夹修改为了 ${DATA_DIR}/log,其中 DATA_DIR 变量是指代 Context.getFilesDir(),它也不存在权限申请之类的问题。测试了将它写死成一个绝对路径,也没有日志文件生成。

基本排除配置文件的问题。

打开 logback 的 debug 模式

将 logback.xml 文件的根元素 <configuration> 添加 debug="true" 属性,重启程序,查看 logcat 输出,发现如下异常:

11:34:06,785 |-ERROR in ch.qos.logback.core.rolling.RollingFileAppender[local_file] - Failed to create parent directories for [/log/app.log]
11:34:06,786 |-ERROR in ch.qos.logback.core.rolling.RollingFileAppender[local_file] - openFile(/log/student.log,true) failed java.io.FileNotFoundException: /log/app.log: open failed: ENOENT (No such file or directory)
	at java.io.FileNotFoundException: /log/student.log: open failed: ENOENT (No such file or directory)
        ...
	at 	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:416)
	at 	at org.mazhuang.OnlineApplication.<clinit>(OnlineApplication.java:21)

初始化日志文件路径失败,但是输出的日志文件路径是 /log/app.log,而不是我配置的 ${DATA_DIR}/log/app.log,这里有问题。

可以看出是 logback 没有正确解析 ${DATA_DIR} 变量,导致日志文件路径错误。但是为什么会这样呢?并没有什么头绪,难道是 logback 的 bug?

检查 logback-android 的 wiki

在想了好久仍然没有头绪之后,浏览 logback-android 的 wiki,发现在介绍 DATA_DIR 等变量的地方,有这么一段以前没留意的描述:

Note these special properties are initialized when the first logger is instantiated (i.e., via LoggerFactory.getLogger()), but that must be done when the application context is available (e.g., in the onCreate method of your Application class or at any point thereafter). Otherwise, the special properties will resolve to empty strings.

这段话大意是,DATA_DIR 等这些变量,是在第一次调用 LoggerFactory.getLogger() 时初始化的,但是必须在 application context 可用之后(例如在 Application 类的 onCreate 方法中或之后的任何时候)。否则,这些变量将解析为空字符串。

结合上面异常堆栈里的信息,可以判断是因为在 Application 类的类初始化(clinit)过程中调用了 LoggerFactory.getLogger(),此时 application context 还不可用,导致 logback 无法正确解析 ${DATA_DIR} 变量。

进一步探究

而为什么 Application 类的类初始化过程中会调用 LoggerFactory.getLogger() 呢?检查代码,发现是在自定义 Application 类及其基类中使用了 @Slf4j 注解,这个注解会生成一个静态的 logger,而这个 logger 的初始化会调用 LoggerFactory.getLogger()——太早了。

参见 @Slf4j 注解源文件里的注释:

Causes lombok to generate a logger field.
Complete documentation is found at the project lombok features page for lombok log annotations .
Example:
  @Slf4j
  public class LogExample {
  }
  
will generate:
  public class LogExample {
      private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(LogExample.class);
  }

解决

去掉自定义 Application 类及其基类上的 @Slf4j 注解,如果需要在 Application 类里打印日志,改为在 onCreate 方法中手动初始化 logger。

又一次印证了认真阅读文档的重要性。:cry:

参考

一些与听歌有关的回忆

作者 Zhuang Ma
2024年4月17日 00:00

在一个早醒的清晨,思绪突然蔓延到「听歌」这个词上。

印象里是上初中后才喜欢上听歌,那时候音乐介质还是磁带。印象比较深的是有一次和几个小伙伴骑自行车去相邻的镇上玩,到地方之后下起了毛毛细雨,他们在喧闹的小摊边买吃的,我囊中羞涩,只好谎称不饿,在背过身等他们的时间里,被不知道哪个方向传来的歌声所吸引,其中有两首的旋律听着挺带劲,不久后在一张盗版磁带里又听到了它们,是《雨一直下》和《单身情歌》。

盗版磁带的好处之一是一盒磁带里能给你凑齐年度金曲,坏处之一是歌词可能印错,《兄弟》的第一句印的是「轻轻的风,像旧梦的声间」,让我纳闷了好久,等到学会五笔打字后才反应过来他是把「音-ujf」打成了「间-uj」。

后来上了高中,周末的晚自习前可以用班里听英语听力的大录音机放歌,那是最松弛的时光。有自告奋勇者也可以上讲台去唱,那时的我远比现在还要自卑又内向,但竟然能壮着胆子上去唱歌,堪称人生的几大奇观之一。

高一时同学带我去上网,申请了个人的第一个 QQ,网名叫做「痛哭的人」,那是伍佰的一首歌。还写过歌词,偷偷给老林的邮箱投稿,结局当然是音信全无。几年间省吃俭用,买齐了所有能买到的伍佰和那时才出道不久的老林的专辑,谁料 MP3 开始流行,高中毕业后不久,连同单放机都不知道被丢到哪个角落去了。

中学时期可能是青春里最迷惘的一段岁月,有一些夜里我总是戴着耳机入睡,拼命地听歌,仿佛听懂了那旋律,就能驱散心头的迷雾,读懂了那歌词,就能明白了爱情。不过现在想来,那时候虽然迷茫,但未来却像问号一样,未知的同时仿佛有着无限可能。如今,过去的未来已成过去,现在的未来却很难说有多么令人期待。

时间轴上离现在最近的大学、参加工作的阶段,在听歌这件事上,留下的能轻易唤起的回忆却并不算多。没有了来自师长和学业的压力,它自然而然地融入了生活,成为了日常的一部分,就像现在读小说,远没有上晚自习时偷偷在书桌下读,生怕被班主任发现那么紧张刺激了。

第一次去听演唱会是在大四,同学临时搞到两张便宜的黄牛票,去洪山体育馆听羽泉,离舞台老远,但结束时嗓子还是喊哑了。后来伍佰到长沙开演唱会,斥巨资买了内场票,和媳妇一起从武汉过去听,兴奋地拍了好多视频,回酒店一听全是我自己的声音。

到现在歌单里最多的,还是年少时最喜欢的那几位歌手的歌,再加上一些影视金曲。不知是缺失了对新生代音乐的兴趣,还是它们确实少了些味道,除了能偶尔被动学会哼唱几句当前的抖音神曲副歌,很少学会什么新歌了。

而今最常见的听歌场景,是在最不想被人打搅的编码时刻,打开播放器,戴上耳机,任手指翻飞,沉浸在和机器的交互里,耳边传来的旋律都很熟悉,熟到不用分心去分辨歌词与音符。

为什么听歌这件事会突然闯进思绪,我想,无非是想起了和它有关的那些人和那些事吧。

后续来了,GitHub 这样处理这件事

作者 Zhuang Ma
2024年3月28日 00:00

我在去年八月份给 GitHub 写信,举报了一个滥用「Used by」特性的事件,GitHub 一直没有给我回信。但是实际上,他们已经悄悄地更新了。

原始事件可以参考当时的记录:发现一种增加在 GitHub 曝光量的方法,已举报,简单来说就是有人在自己的项目里虚假声明自己依赖了大量知名项目,包括很多无法作为依赖包的项目,从而让自己的头像展示在这些知名项目的「Used by」栏目里。

最近留意了一下,发现我的一个 Star 数量较多的项目 awesome-adb 旁边已经不再展示「Used by」栏目了——本来就不应该展示。

翻阅了 GitHub 的 docs 更新记录,发现了这样一次 提交

从这段文档里可以看到,一个项目是否展示「Used by」栏目,原本是需要满足以下三个条件:

  1. 项目的 dependency graph 是启用的;
  2. 项目包含有发布在受支持的包管理系统里的 package;
  3. package 在包管理系统上的资料里,源码链接指向该公开项目;

其实……原本这三个条件如果都认真执行的话,那些无法作为依赖包的项目是不会展示「Used by」栏目的,可事实是在我发信举报的那段时间,是会展示的,这就给滥用行为提供了空间。

而这次更新,GitHub 给「Used by」栏目展示又加上了一个条件:

  • 超过 100 个项目依赖你项目的 package;

这样做的效果,至少能让那些滥用者的头像不会作为几分之一,显眼地出现在知名项目的「Used by」栏目里了,要么不出现,要么就与至少上百个人一起出现,也就是说,他们的滥用行为的曝光/收益会大打折扣了。

虽然跟我期待的处理不太一样,但好歹也算是有进步了。希望 GitHub 这次不只是加上了这个第四条,而是将前面三条也更认真地执行了,这样才能更好地避免滥用。

当然我还有个疑惑,为什么不给我回信?:thinking:

科技奇趣|为什么 Excel 认为 1900 年是闰年?

作者 Zhuang Ma
2024年3月14日 00:00

我们先来看一下现象:

实际上 1900 年不是闰年,没有 2 月 29 日,所以很明显 这是 Excel 的一个 Bug

发现

我之所以会留意到这个,是因为最近在做一个绩效核对的小工具,需要用 Python 读取和处理销售交上来的 Excel。

销售交上来的东西总是稀奇古怪,比如有一列是要填日期,交上来的表格里,有的读出来是日期类型,有的读出来是字符串类型,这都还好说,日期类型直接用,字符串按格式解析成日期,就好了。但这天发现有个销售交上来的表格里,这一列读出来是数字类型。

比如 2024-02-01,读出来对应数字 45323

怎么将这个数字转换成日期呢?

首先搜索了微软的官方文档,找到关于 Date systems in Excel 的说明:

Excel supports two date systems, the 1900 date system and the 1904 date system. Each date system uses a unique starting date from which all other workbook dates are calculated. All versions of Excel for Windows calculate dates based on the 1900 date system. Excel 2008 for Mac and earlier Excel for Mac versions calculate dates based on the 1904 date system. Excel 2016 for Mac and Excel for Mac 2011 use the 1900 date system, which guarantees date compatibility with Excel for Windows. … In the 1900 date system, dates are calculated by using January 1, 1900, as a starting point. When you enter a date, it is converted into a serial number that represents the number of days elapsed since January 1, 1900.

就是说,除了 Excel for Mac 的早期版本外,都是默认采用 1900 date system,以 1900-01-01 作为起点(第 1 天)。

于是根据这个信息写一个函数来将数字转换成日期,但是 翻车了……

from datetime import datetime, timedelta

def int_to_date(s):
    date_zero = datetime(1900, 1, 1)
    delta = timedelta(days = s - 1)
    return date_zero + delta

print(int_to_date(45323))

# 输出 2024-02-02 00:00:00

Excel 表格里是 2024-02-01,读出来是 45323,咋按 1900-01-01 作为第一天,反算出来 45323 却是 2024-02-02 了呢?

探究

于是一番搜索,先是找到了 OpenOffice 论坛里的讨论 Why the base date is 1899-12-30 instead of 1899-12-31?,里面有如下信息:

The earliest date Excel handles sensibly is 1900-01-01, which is “day 1” (not zero). This makes “Excel epoch” (day zero) 1899-12-31. However, Excel inherited an error from previous spreadsheet apps which assumed that 1900 was a leap year. The nonexistent 29th of February 1900 is counted in Excel time spans, just like it used to be in Lotus 123 and (IIRC) SuperCalc, and probably in most other relevant spreadsheet apps.

有意思了……于是继续在维基百科的 Microsoft Excel 词条上找到了佐证信息:

Excel的时间系统中,会认为1900年2月29日是有效日期,也就是1900年为闰年,但实际上并不是。这是源于模仿早期竞品Lotus 1-2-3上的缺陷而引入的特性,由于Lotus 1-2-3的时间纪元以1900年起始,之后的时间为差值累加,导致其时间体系一开始就认为1900年是闰年,而Excel为了兼容Lotus 1-2-3的文件格式,也保留了这个缺陷作为特性而不进行修复,即使至今最新版本已不需要兼容Lotus 1-2-3。

里面还给出了微软官方的相关解释链接:Excel incorrectly assumes that the year 1900 is a leap year,并且讲述了 Excel 的发展历史,挺有趣的,可以一读。

至此,就破案了。

数字到日期的换算

我们上面提供的数字到日期的换算的方案,做个小修正就能使用了:

from datetime import datetime, timedelta

def int_to_date(s):
    date_zero = datetime(1899, 12, 30)
    delta = timedelta(days = s)
    return date_zero + delta

print(int_to_date(45323))

# 输出 2024-02-01 00:00:00

当然这个程序并不完美,比如计算 60 以内的数字,算出来的就与 Excel 上显示的日期不一致,但 who cares……毕竟 Excel 上有 1900-02-29 这种你永远不会用到的日期 :laughing:

参考链接

如何接住空投给 GitHub 用户的「泼天富贵」?

作者 Zhuang Ma
2024年3月4日 00:00

背景

我的公众号里前几天发的一篇文章小火了一把,阅读量到了 5000+(看官您别笑,对于我这种没什么流量的号,这已经是顶流了)。

想着看看我的号里哪些内容最受欢迎,于是翻了一下历史群发文章的数据统计,阅读量最高的是这两篇:

都是关于空投和钱的,而且最近也陆续有一些网友加我微信,咨询如何能获得类似空投的领取资格——和家人交流了一下,猜想可能是因为经济下行,大家关心的都是如何搞钱存钱。

我算是比较幸运的,这两次空投都有领取资格,在不需要额外付出什么成本的前提下,合计入账了 RMB 5000+,在如今挣点钱并不容易的大环境下,还是很香的。

我猜想后续这样的空投应该也还会出现。我就趁机在此,就大家普遍关心的话题,把我的理解小结一下,供大家参考。

分析

首先看一下作为一名 GitHub 用户,这两次空投领取资格 门槛 分别是怎么样的。

2024 年初的 Starknet 空投:

  • 你在 2023-11-15 前,向整个 GitHub 上 star 数 top 5000 之一的项目至少贡献过 3 次 commit;

  • 至少有一次 commit 发生在 2018 年及以后。

2020 年初的 Namebase 空投:

  • 在 2019-02-04 这一天所在的那一周,你的 GitHub 账号有 15 个以上的 followers;

  • 保留有当时的 SSH / PGP 私钥。

从中我提取到的关键词是 贡献影响力

这些机构空投给 GitHub 用户,我估计一方面是回馈开发者,特别是参与构建与它们的项目相关的基础设施和技术项目的开发者,鼓励创新,另一方面也是借此扩大它们项目的影响力,吸引更多的人关注和参与进来。

建议

虽然说不准未来的空投会采取什么样的角度来制定领取资格门槛,但我们从现在开始着手「埋伏笔」肯定是必要的。

接下来是对在 GitHub 活动的建议,其实就是三板斧——从 GitHub 学、在 GitHub 练、向 GitHub 作贡献

一、从 GitHub 学:

  • Follow 你感兴趣的领域厉害的人物,持续关注他们在 GitHub 上的活动,学习他们的协作方式,了解他们关注的优秀项目和资料;
  • 找到与你工作和学习相关的有影响力的项目,挑选感兴趣的,进行深入学习;
  • 学习并掌握 GitHub 的工作流,学习版本控制、Issues、Pull Request 的规范;
  • 在需要寻找开源库或工具时,优先上 GitHub 搜索。

二、在 GitHub 练:

  • 将你自己的玩具项目源码大胆发上去,不断用你学习到的优秀的模式和架构对它们进行重构,形成你个人比较固定的流程和规范;
  • 使用 GitHub Pages 搭建自己的个人主页和博客,勤记录和分享笔记与心得。

三、向 GitHub 作贡献:

  • 创建你自己的实用项目,并长期维护——可以是代码,也可以是清单、文档、手册;
  • 在发现你使用的开源项目的问题或漏洞时,参考下面的流程去寻求帮助或协助解决,即使只是帮助修正了文档里的一处语法错误,这个世界也因你而变得更加完美了一点点:

小结

看到这里,可能有的朋友会问,「如果我都有这个能力和影响力了,还在乎空投这仨瓜俩枣?」

是的,你说得对,如果你依上面的建议提升了自己的能力,构建了自己的项目和影响力,给开源社区贡献了自己的力量,你所获得的有形和无形的收益可能远超自己的想象。

面对新的空投,你将有选择的自由。

前端|基于 Layui 实现动态搜索选择框

作者 Zhuang Ma
2024年3月1日 00:00

后端程序员的前端笔记,含金量,你懂的 :-P

需求

网页端实现动态搜索选择框,要求:

  1. 下拉选项列表能根据用户输入内容动态刷新;
  2. 最终提交的值必须是由选项列表中点选的;
  3. 基于 Layui。

方案

一开始根据印象里常见的搜索选择框的样式,一直在探索如何基于 <select> 来实现。Layui 的搜索选择框并没有暴露监听输入内容的事件接口,在网上找到了两个思路,但实现得都不够完美。

一是参考 https://www.cnblogs.com/zqifa/p/layui-select-input-1.html,在 <select> 上覆盖一个 <input>,监听 <input> 的输入内容然后触发模糊搜索,进而触发更新 <select> 的选项列表。可以基本达成需要的效果,有一个问题是选择列表展示后,必须选择一项才能关闭选项列表,而期望是点击空白区域选项列表自动关闭。

二是参考 https://gitee.com/layui/layui/issues/I6N5MZ,监听经过 Layui 渲染 <select> 后生成的 <input> 元素的事件,进而触发选项列表的刷新。这个方案的思路是挺好的,但是同样有一些小问题,比如下拉选项的展示/隐藏、输入焦点、输入内容保持等,都需要自己一一去干预。

这时在 Layui 的仓库找到 这个 Issue,贤心大大这样回应网友「能不能在选择框上加上可输入可下拉可搜索」的提问:

select 组件的定位就是只能赋值选项列表中的值,包括搜索,也只是从选项中匹配。若要支持自定义输入的值,可以借助 input + dropdown 组件来自定义实现哦。

受此启发,我又思考了一下需求里的「搜索」:

  • 我们的下拉选项列表完全由后端根据输入内容返回;
  • Layui 的 select 搜索选择框的搜索,是根据输入内容匹配现有候选列表,纯前端行为;

看了下 Layui 文档后发现 dropdown 有专门的 reloadData 的 API,经尝试后最终选择了基于 Layui 的 dropdown 组件来实现。

实现

效果如下:

示例代码如下:

  • 其中 mockData 实现应按需替换成 ajax 请求,成功拿到数据之后再 reloadData
  • 表单提交时需要使用 id 作为参数值,可以在 click 的时候给 input 添加自定义属性如 data-id,在输入监听事件里删除该属性值。
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Demo</title>
  <link href="https://unpkg.com/layui@2.9.6/dist/css/layui.css" rel="stylesheet">
</head>
<body>
<div class="layui-inline layui-padding-5">
  <input name="" placeholder="请搜索或选择" class="layui-input" id="ID-dropdown-demo">
</div>
  
<script src="https://unpkg.com/layui@2.9.6/dist/layui.js"></script> 
<script>
layui.use(function(){
  var dropdown = layui.dropdown;
  var $ = layui.$;
  var inst = dropdown.render({
    elem: '#ID-dropdown-demo',
    data: [],
    click: function(obj){
      this.elem.val(obj.title);
      this.elem.attr('data-id', obj.id)
    }
  });

  $(inst.config.elem).on('input propertychange', function() {
    var elem = $(this);
    var value = elem.val().trim();
    elem.removeAttr('data-id');

    var dataNew = mockData(value);
    dropdown.reloadData(inst.config.id, {
      data: dataNew
    })
  });

  $(inst.config.elem).on('blur', function() {
    var elem = $(this);
    var dataId = elem.attr('data-id');
    if (!dataId) {
        elem.val('');
    }
  });
  
  function mockData(value) {
    return [
      {id: 1, title: value + '1'},
      {id: 2, title: value + '2'}
    ];
  }
});
</script>
 
</body>
</html>

小结

冷静地想清楚自己的需求和场景,有助于更快找到合适的组件和方案。

GitHub 用户福利,符合条件可领取约 1500 元现金

作者 Zhuang Ma
2024年2月27日 00:00

看到公众号沉默王二发的一篇文章 《GitHub赚了211美刀后的感触》,用自己的 GitHub 账号尝试了一下,1500 元现金到账,有兴趣的朋友们可以一试。

省流

Starknet 基金会启动了第一轮 Starknet 供应计划,将向近 130 万个地址分发超过 7 亿个 Starknet 代币(STRK),其中有 2.1% 分发给开源开发者。STRK 可以理解为一种数字货币,领取到后可以通过交易转换成现金。

可以通过 https://provisions.starknet.io 网站查询是否有资格领取 STRK。

Update 2024/02/29 ref https://www.guozaoke.com/t/107113#reply7

符合条件的 GitHub 用户可以在下面两个文件之一搜到自己的 ID:

如果发现有资格,可以继续阅读;如果没有资格,就不用浪费时间继续看了。另外,可以推荐给你在 GitHub 活跃的朋友试试。

领取资格查询

  1. 打开 https://provisions.starknet.io/ 网站;

  2. 往下翻,找到 Eligibility check only 链接,点击打开:

    eligibility check

  3. 切换到 GitHub,输入 GitHub 用户名,点击 Go:

    github-go

  4. 如果有资格,会看到如下界面:

    has-eligibility

    点 See your allocation,会看到能领取的 STRK 数量:

    strk-count

领取 STRK

  1. 在上面领取资格查询的最后一步点击 Disconnect,会回到第 2 步界面,点击 Claim STRK;

  2. 勾选,Confirm:

  3. 选择钱包,我这里用的 Argent X,根据提示下载安装浏览器插件,生成一个钱包地址:

  4. 选择 GitHub,Sign in:

  5. 按提示操作,看到如下界面就是领取成功了:

转换成现金

  1. 注册一个数字货币交易所账户,建议选择头部交易所,如币安、欧易,我使用的是 欧易(OKX)

  2. 在 OKX 的资金账户里 充币-选择 STRK-生成充币地址,选择将资金充入交易账户,复制充币地址:

  3. 打开之前安装的 Argent X 钱包浏览器插件,点击 Send,输入充币地址,填入 STRK 数量(可以点击 Max 选择最大转出数量),点击 Review send:

  4. 稍等一小会,刷新 OKX 应该就能看到 STRK 充进来了,在交易菜单里找到 币币,搜索 STRK/USDT,点击后输入数量,市价卖出:

  5. 资金划转,将 USDT 从 交易账户 转到 资金账户;

  6. 买币-快捷买币-出售,USDT to CNY,全部出售,支付宝收款,支付宝到账后确认并放币即可,我最终到账 1533.8 元人民币。

小结

天上不会掉馅饼,但是定向投放的合法福利,我们要接住!

类似的空投,2020 年初也经历过一次,当时也有记录:GitHub 用户专属福利,实际到账 3K+,Namebase Airdrop,当时是将 HNS 转换到 BTC 然后提现的,本次操作时看了下,BTC 的价格相对当时翻了约五倍,所以……将数字货币留着不提现,等增值也是一种思路。

在 Starknet 的网站上,有提到空投给 GitHub 用户的原因:

In addition, STRK will be distributed to those who helped to develop the larger ecosystem of open-source software and infrastructure, as their work has become a public good and contributed to the emergence of a more open and inclusive web.

所以如果你符合领取资格,你必然也曾为构建有影响力的开源软件和基础设施贡献过力量,这是你应得的!

参考链接

DIY|ikbc C87 机械键盘有线改蓝牙小结

作者 Zhuang Ma
2024年2月5日 00:00

前一阵把家里的 Filco 圣手二代机械键盘单模改三模后,体验挺不错,想着改装的工具买了只用一回也比较浪费,顺手把放公司用的 ikbc C87 也改了吧。

本次仍然使用与之前相同的方案,具体方案及操作过程可以参考 DIY|Filco 圣手二代机械键盘单模改三模,以及里面列举的参考链接,在此不展开,重点小结一下改装过程及使用过程中的一些新的体会。

改装过程因为有了改装上一把的经验,本次更加熟练和顺利,但也得到了两点新经验:

  1. 为了开拓底板上安装模块和电池的空间,一般会干掉一些栅格,使用美工刀+尖嘴钳能很轻松完成;
  2. 钻孔的时候,把底板和上盖扣在一起后再钻,可以避免孔位没对齐的尴尬。

施工图:

ikbc C87 内部

改装完到现在也使用了一个多月了,结合我自己的体验以及网友在上一篇文章的留言,有个痛点是续航。

续航应该是取决于电池容量、使用强度、键盘本身和改装模块的耗电情况等,与厂家量产的原生三模还是有非常明显的差距的。比如我手上的两把:

  • Filco 圣手二代 87 键,塞进 5000mAh 电池,放在家低频度使用,充一次电用两个月应该没问题;
  • ikbc C87,使用 3000mAh 电池,放在公司高强度使用,充一次电只能使用一星期左右。

有点电量焦虑。

所以,现在如果有人问我要不要把手上的键盘自己有线改无线,我的建议是,如果能找到手感合适价钱合适的,直接买一把新的吧 :-P

❌
❌