白季飞龙的个人主页

警惕挂着开源的招牌到处坑蒙拐骗的垃圾项目,比如iBase4J

开源界,本是技术爱好者百花齐放、各显其能的地方。但是,不管什么好东西,到了这块奇葩的土地都能变了味。现在的开源界,真的是鱼龙混杂,有些开源软件,不知道是噱头喊得高,还是star刷得好,竟能凭借一身垃圾代码招摇撞骗,误人子弟。垃圾不扫,这世界只能越来越臭。以iBase4J为例,我来给大家分析一下,让大家提高警惕,尤其是编程新手,不要上了贼船,免得抱撼终身。

1. iBase4J是什么东西

iBase4J,作者自称是一个JAVA(原文如此)分布式快速开发平台。项目的Github地址是https://github.com/iBase4J/iBase4J。截至本文的撰写时间(2018年7月3日,自由日前夕),该项目已有943个Star。在码云https://gitee.com/iBase4J/iBase4J上,该项目甚至有6422个Star,而且竟然是GVP(码云最有价值开源项目),这就是所谓鸡犬升天?

项目的官方介绍:

JAVA分布式快速开发平台:Spring,SpringBoot 2.0,SpringMVC,Mybatis,mybatis-plus,motan/dubbo分布式,Redis缓存,Shiro权限管理,Spring-Session单点登录,Quartz分布式集群调度,Restful服务,QQ/微信登录,App token登录,微信/支付宝支付;日期转换、数据类型转换、序列化、汉字转拼音、身份证号码验证、数字转人民币、发送短信、发送邮件、加密解密、图片处理、excel导入导出、FTP/SFTP/fastDFS上传下载、二维码、XML读写、高精度计算、系统配置工具类等等。SpringBoot版本:https://github.com/iBase4J/iBase4J-SpringBoot http://gitee.com/signup?inviter=iBase2J

2. 我与iBase4J的渊源

话说,我本不是什么路见不平,拔刀相助的侠客,而是鲁迅笔下的一个小小的看客。对于这种垃圾项目,敬而远之对我来说本是最佳选择。但是,自称iBase4J原作者的"万明"欠了我五千多的工资不发(如此如此这般这般),就跟我结下了梁子。

iBase4J的作者叫“沈华杰”,河南人,万明(南充巴蜀文化传媒)的小弟。万明曾向我得意地说他才是iBase4J的原作者。我在这家公司负责前后端接口调试,后端是沈华杰用他的iBase4J写的。我被克扣工资愤而离职后,万明借口我什么都没做,交接工作没做好(我写了1万字以上的交接文档,能讲的都讲了,万明说新同事看了我的文档完全看不懂,接手不了,我调试过的接口全部都重写了(服,接口不都是你家沈华杰写的?我只是来调试和维护的)),不发工资,而且是一毛不发。

狼狈为奸者,一路货色也。当初维护iBase4J写的项目,搞得我焦头烂额。现在正好记录一下,让大家共赏。

3. 从项目主页看起

首先,JAVA 四个字母用的是全大写。众所周知,Java 名字的由来是印尼的爪哇岛,是地名,不是词组的简写。作为一个合格的 Java 程序员,对于给了咱饭碗的 Java 语言,至少要尊重人家的名字吧。全大写的 JAVA,由一个 Java 程序员拼写出来,完全是不伦不类。

然后,看 README 中的这句话

持久层:mybatis持久化,使用MyBatis-Plus优化,减少sql开发量;aop切换数据库实现读写分离。Transtraction注解事务。

文法内容先略过不表。单说Transtraction,英文中完全没有这个词汇。事务,作为数据库的核心概念之一,相信程序员们对于这个词都熟悉得很。我当然也不例外,当初打开这个项目主页,一眼就瞅到这个不三不四的单词,二话没说Fork下来改正拼写,然后提交Pull request。我好心好意帮你修正这低级错误,为了不伤你自尊,提交信息我还是用的纯英文的Update readme.md。结果,到现在都没改过来。没有任何反馈,就在二十多天后悄悄把这个Pull request给关了。

首页的其他地方也有槽点,不过我不是来找碴的,先架起项目再说。

4. 搭建环境与运行项目

iBase4J有SpringBoot版,是在另一个git仓库(https://github.com/iBase4J/iBase4J-SpringBoot)。既然有SpringBoot版,就优先使用SpringBoot版吧。

先查查文档怎么介绍的。结果只能在 README 里头找到这两句有点用的:

启动方法: SysServiceApplication.java SysWebApplication.java

具体的文档还需要加QQ群才能下载:

加入QQ群538240548 交流技术问题,下载项目文档和一键启动依赖服务工具。

既然没有文档,就直接导入IDE执行吧。

先把项目源码克隆到本地,再用 Jetbrains IDEA 打开。IDEA 会在后台自动下载 Maven 依赖。

依赖下载完成后,先启动 SysServiceApplication.java ,控制台一屏的报错。

先不说这报错,先看看日志的打印。SpringBoot默认情况下,打印的是彩色的日志,报错信息红色显示,十分醒目。但这个 ibase4J 放着 SpringBoot 精心设计好的日志格式不用,非要在 resources 目录创建一个 log4j2.xml ,打印了满屏的黑色。还有一堆乱码日志 [main] DEBUG [DefaultVFS:102] - Reader entry: ����4? 不知道是怎么搞出来的。

接下来看具体的报错。

报的第一个错是

main ERROR Unable to create file /output/logs/iBase4J-SYS-Service-dev/iBase4J-SYS-Service.log java.io.IOException: Could not create directory /output/logs/iBase4J-SYS-Service-dev
    at org.apache.logging.log4j.core.util.FileUtils.mkdir(FileUtils.java:127)
    at org.apache.logging.log4j.core.util.FileUtils.makeParentDirs(FileUtils.java:144)
    at org.apache.logging.log4j.core.appender.rolling.RollingFileManager$RollingFileManagerFactory.createManager(RollingFileManager.java:627)

显然,是 log4j 创建日志文件失败,日志的根目录竟然是 /output。你日志文件默认不写到工作目录,非要在系统根目录创建一个文件夹 output ?在类UNIX系统上,普通用户必然没有权限在系统根目录创建文件夹。好吧,那就先把这个日志文件夹创出来。执行命令 sudo mkdir /output && sudo chmod 777 /output,创建文件夹 /output 并把权限开到最大,不然普通用户也没有这个 /output 目录的写权限。

目录建好后,再次运行,第二个报错是:

[main] ERROR [DruidDataSource:870] - init datasource error, url: jdbc:mysql://127.0.0.1:3306/ibase4j?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=CONVERT_TO_NULL&allowMultiQueries=true&serverTimezone=PRC&useSSL=false
java.sql.SQLException: Access denied for user 'root'@'localhost' (using password: YES)
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:127) ~[mysql-connector-java-8.0.11.jar:8.0.11]`

数据库连接失败,访问被拒绝。无可厚非,毕竟我还没配数据库呢。理论上,数据库应该配在 Spring Boot 的标准配置文件 application.yml 里头。但是里头没有。找着一个 resources/config/dev/jdbc.properties ,看名字数据库应该就在这里配置了。

在这里配置也算不错了,ibase4J 之前的数据库可是配置在 pom.xml 中的。2018年5月18日,我离职6天后,沈华杰锅没地方甩了,估计也被自己这奇葩的配置方式绕晕了,老老实实把数据库配置放到了 properties 文件里。

数据库连接的默认配置如下:

druid.reader.url=jdbc:mysql://127.0.0.1:3306/ibase4j\u003fuseUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=CONVERT_TO_NULL&allowMultiQueries=true&serverTimezone=PRC&useSSL=false
druid.reader.username=root
druid.reader.password=68NKG7n1mN8rErEfbag2qM==
druid.writer.url=jdbc:mysql://127.0.0.1:3306/ibase4j\u003fuseUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=CONVERT_TO_NULL&allowMultiQueries=true&serverTimezone=PRC&useSSL=false
druid.writer.username=root
druid.writer.password=68NKG7n1mN8rErEfbag2qM==

竟然把半角问号(?)用 UNICODE 码(\u003f)表示,这个逼装的,我给打99分,不打100分是怕你骄傲。

数据库配置好后,再启动应用。这下报的错是:

[main] ERROR [SpringApplication:842] - Application run failed
java.lang.RuntimeException: 解密错误,错误信息:
    at top.ibase4j.core.util.SecurityUtil.decryptDes(SecurityUtil.java:134) ~[ibase4j-common-3.4.4.jar:?]
    at top.ibase4j.core.config.Configs.postProcessEnvironment(Configs.java:53) ~[ibase4j-common-3.4.4.jar:?]
    at org.springframework.boot.context.config.ConfigFileApplicationListener.onApplicationEnvironmentPreparedEvent(ConfigFileApplicationListener.java:183) ~[spring-boot-2.0.1.RELEASE.jar:2.0.1.RELEASE]

好吧,数据库密码竟然要加密处理。找到解密逻辑,代码如下:

if ("druid.password,druid.writer.password,druid.reader.password".contains(keyStr)) {
    String dkey = (String)map.get("druid.key");
    dkey = DataUtil.isEmpty(dkey) ? Constants.DB_KEY : dkey;
    value = SecurityUtil.decryptDes(value.toString(), dkey.getBytes());
    map.put(key, value);
}

由于默认情况下 druid.key 是空值,加密的密钥取了默认值 90139119 ,这又是什么鬼?

调用top.ibase4j.core.util.SecurityUtil#encryptDes(java.lang.String, byte[])算出加密后的本机数据库密码后,更新数据库配置,再次启动应用。这次报的错是

[main] ERROR [JobStoreSupport$ClusterManager:3926] - ClusterManager: Error managing cluster: Failure obtaining db row lock: Table 'foo.qrtz_locks' doesn't exist
org.quartz.impl.jdbcjobstore.LockException: Failure obtaining db row lock: Table 'foo.qrtz_locks' doesn't exist
    at org.quartz.impl.jdbcjobstore.StdRowLockSemaphore.executeSQL(StdRowLockSemaphore.java:157) ~[quartz-2.3.0.jar:?]
    at org.quartz.impl.jdbcjobstore.DBSemaphore.obtainLock(DBSemaphore.java:113) ~[quartz-2.3.0.jar:?]

显然,缺少 Quartz 相关的数据表。导入sqls/3.quartz.mysql.sql后,再次启动应用。这次报错是:

2018-07-04 11:03:30.287 [main] DEBUG [RetryLoop:171] - Retry-able exception received
org.apache.zookeeper.KeeperException$ConnectionLossException: KeeperErrorCode = ConnectionLoss for /dubbo/org.ibase4j.service.SchedulerService/providers
    at org.apache.zookeeper.KeeperException.create(KeeperException.java:102) ~[zookeeper-3.4.12.jar:3.4.12--1]

显然,Zookeeper没启动。启动Zookeeper,在启动应用,接下来的报错是:

[main] ERROR [SpringApplication:842] - Application run failed
org.springframework.jdbc.BadSqlGrammarException:
### Error querying database.  Cause: java.sql.SQLSyntaxErrorException: Table 'foo.sys_user' doesn't exist

显然,缺用户表。导入 sqls/1.iBase4J.sql,报错ERROR 1044 (42000) at line 16: Access denied for user 'foo'@'%' to database 'ibase4j'。iBase4J 竟然把数据库名写死在 SQL 里。

去掉数据库选择的 SQL , 再次导入,这次报错 ERROR 1273 (HY000) at line 232: Unknown collation: 'utf8' 。不懂,应该是我机器上没有叫utf8的字符集吧,可能新版本MySQL把这个字符集改名了。

去掉字符集设置,这次终于导入成功。启动应用,这次终于算是启动完成吧:

[main] INFO [ApplicationReadyListener:35] - =================================
[main] INFO [ApplicationReadyListener:38] - 系统[SysServiceApplication]启动完成!!!
[main] INFO [ApplicationReadyListener:39] - =================================

由于这个应用 "SysServiceApplication" 启动的是 Java RPC 服务,因此得启动一个 RPC 的客户端才能验证能否正常工作。

启动 org.ibase4j.SysWebApplication,一次启动完成。这个应用提供的是系统管理的 HTTP 接口。接下来测试一下。

访问 http://localhost:8088,返回一个302,跳转到 /index.html ,这个 /index.html 又302跳转到 /unauthorized,返回如下的 JSON:

{
  "code": "401",
  "msg": "您还没有登录",
  "timestamp": "1530675700497"
}

一个接口的首页,竟然用跳转,还跳了两次,也是没谁了。。。

访问 http://localhost:8088/swagger-ui.html,Swagger能进去。那就先看看这个接口的设计吧。

|HTTP方法 |URI |Swagger描述 |我的备注 | |---------------|-----------------------|---------------|---------------| |PUT |/user/read/list |查询用户 |查询所有用户 | |PUT |/user/read/detail |用户详细信息 |查询单个用户 | |POST |/user |修改用户信息 |新增及更新用户 | |DELETE |/user |删除用户 |删除用户 |

显然,这接口的设计跟 REST 规范相去甚远,但是用 "PUT" 来进行查询操作也太匪夷所思了吧。新增与修改共用一个接口,这 iBase4J 不仅开源还会节流呢

找到登录接口 POST /login,空参先调用一下,接口报错:

{
    "code": "500",
    "msg": "系统走神了,请稍候再试.",
    "timestamp": "1530698198011"
}

所有的系统错误,全部返回“系统走神了,请稍候再试.”。。。

控制台报错:

[http-nio-8088-exec-19] ERROR [WebUtil:142] - java.lang.IllegalStateException: getInputStream() has already been called for this request
    at org.apache.catalina.connector.Request.getReader(Request.java:1232)
    at org.apache.catalina.connector.RequestFacade.getReader(RequestFacade.java:504)
    at javax.servlet.ServletRequestWrapper.getReader(ServletRequestWrapper.java:225)
    at top.ibase4j.core.util.WebUtil.getRequestBody(WebUtil.java:135)
    at top.ibase4j.core.util.WebUtil.getParameter(WebUtil.java:164)
    at top.ibase4j.core.interceptor.EventInterceptor.afterCompletion(EventInterceptor.java:82)

意思是输入流已经打开过了,不能再次打开。做Java竟然不知道流只能打开一回。。。

这个bug之所以顽固到现在,是因为只要请求体不为空,就不复现。

登录的具体逻辑在 org.ibase4j.core.shiro.AuthorizeRealm 中,代码如下:

// 登录验证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authcToken)
        throws AuthenticationException {
    UsernamePasswordToken token = (UsernamePasswordToken)authcToken;
    Map<String, Object> params = new HashMap<String, Object>();
    params.put("enable", 1);
    params.put("account", token.getUsername());
    List<?> list = sysUserService.queryList(params);
    if (list.size() == 1) {
        SysUser user = (SysUser)list.get(0);
        StringBuilder sb = new StringBuilder(100);
        for (int i = 0; i < token.getPassword().length; i++) {
            sb.append(token.getPassword()[i]);
        }
        if (user.getPassword().equals(SecurityUtil.encryptPassword(sb.toString()))) {
            ShiroUtil.saveCurrentUser(user.getId());
            saveSession(user.getAccount(), token.getHost());
            AuthenticationInfo authcInfo = new SimpleAuthenticationInfo(user.getAccount(), sb.toString(),
                user.getUserName());
            return authcInfo;
        }
        logger.warn("USER [{}] PASSWORD IS WRONG: {}", token.getUsername(), sb.toString());
        return null;
    } else {
        logger.warn("No user: {}", token.getUsername());
        return null;
    }
}

其中 sysUserService 是一个 RPC 服务。现在这种调用比以前可强太多了,以前我在职的时候,全部RPC调用都走的是同一个接口, 代码如下:

// 权限
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
    Long userId = (Long)ShiroUtil.getCurrentUser();
    Parameter parameter = new Parameter("sysAuthorizeService", "queryPermissionByUserId", userId);
    logger.info("{} execute queryPermissionByUserId start...", parameter.getNo());
    List<?> list = sysProvider.execute(parameter).getResultList();
    logger.info("{} execute queryPermissionByUserId end.", parameter.getNo());
    for (Object permission : list) {
        if (StringUtils.isNotBlank((String)permission)) {
            // 添加基于Permission的权限信息
            info.addStringPermission((String)permission);
        }
    }
    // 添加用户权限
    info.addStringPermission("user");
    return info;
}

看这一行Parameter parameter = new Parameter("sysAuthorizeService", "queryPermissionByUserId", userId);,调用的服务、 调用的方法全部通过字符串来传递,返回的结果再从Object向下强转。广义上说,应该算是自定义了一套协议了吧。 通过字符串调用服务,PHP和Ruby都不这么干吧?牛!

具体的调用逻辑:

@Override
public Parameter execute(Parameter parameter) {
    String no = parameter.getNo();
    logger.info("{} request:{}", no, JSON.toJSONString(parameter));
    Object service = applicationContext.getBean(parameter.getService());
    try {
        String method = parameter.getMethod();
        Object[] param = parameter.getParam();
        Object result = InstanceUtil.invokeMethod(service, method, param);
        Parameter response = new Parameter(result);
        logger.info("{} response:{}", no, JSON.toJSONString(response));
        return response;
    } catch (Exception e) {
        logger.error(no + " " + Constants.Exception_Head, e);
        throw e;
    }
}

这个方法参数用Parameter,返回类型还用Parameter,这就是传说中的惜码如金吧?

rpc调用通过top.ibase4j.core.base.provider.IBaseProvider#execute(top.ibase4j.core.base.provider.Parameter)方法,方法的参数和返回值均是一个Parameter对象。做参数时,调用构造函数Parameter(beanName, methodName, argList)。做返回结果时,getResult取对象,getResultList取列表,getResultPage取分页,getResultLong取整数

具体的查询数据库代码如下

@Override /** 根据参数查询 */
public List<T> queryList(Map<String, Object> params) {
    if (DataUtil.isEmpty(params.get("orderBy"))) {
        params.put("orderBy", "id_");
    }
    if (DataUtil.isEmpty(params.get("sortAsc"))) {
        params.put("sortAsc", "desc");
    }
    List<Long> ids = mapper.selectIdPage(params);
    List<T> list = queryList(ids);
    return list;
}

作为一套框架,字符串不传参数,不传枚举,不传常量,也不写文档,硬生生地就写在 Map 里。 至于sortAscselectIdPage之类的命名风格,还需要细细品味。

由于时间有限,我就不慢慢深入了。在此再列出几条突出的槽点,供大家品鉴:

1. 项目的配置文件到处都是

修改配置的时候,寻找配置项所在的位置极其痛苦。甚至部分配置扔在jar包里,部署后想要修改这些配置,还得解包jar,再重新打包。自定义的配置是通过org.springframework.core.io.support.PathMatchingResourcePatternResolver#getResources("classpath\*:config/\*.properties")方法获取。此方法依次从当前项目、依赖模块、依赖jar的config目录读取properties文件,不搞懂优先规则,配置好的东西就被莫名其妙地覆盖掉了。

2. 数据库查询的queryById的代码匪夷所思

private T queryById(Long id, int times) {
    CacheKey key = CacheKey.getInstance(getClass());
    T record = null;
    if (key != null) {
        try {
            record = (T)CacheUtil.getCache().get(key.getValue() + ":" + id, key.getTimeToLive());
        } catch (Exception e) {
            logger.error(Constants.Exception_Head, e);
        }
    }
    if (record == null) {
        String lockKey = getLockKey(id);
        String requestId = Sequence.next().toString();
        if (CacheUtil.getLock(lockKey, "根据ID查询数据", requestId)) {
            try {
                record = mapper.selectById(id);
                saveCache(record);
            } finally {
                CacheUtil.unLock(lockKey, requestId);
            }
        } else {
            if (times > 3) {
                record = mapper.selectById(id);
                saveCache(record);
            } else {
                logger.debug(getClass().getSimpleName() + ":" + id + " retry getById.");
                sleep(100);
                return queryById(id, times + 1);
            }
        }
    }
    return record;
}

递归调用、睡眠100毫秒、计次。。。

queryById 先读缓存,如果缓存有效,直接返回。 否则,获取锁(60秒超时)并调用com.baomidou.mybatisplus.mapper.BaseMapper#selectById。 没获取到锁,则继续获取两次,如果还没获取到锁,则直接#selectById。 查询到的数据继续加入缓存。

最基本的用ID查询数据,你们能看懂吗?

3. 用户的Token由客户端生成

用户的Token不在服务端生成,反而要客户端生成,还没有格式限制,你不嫌头大?

4. 一个用户一个密钥

正常情况下,只要私钥保存在服务器,一对密钥就足够安全了。可是这iBase4J的密钥要客户端调接口去申请, 服务端生成密钥对保存在Redis里。一个用户一个密钥,哥们,你真有苦!

5. 签名算法把FBI都弄哭了

iBase4J的签名算法是:请求参数按key顺序排序,组成URL查询串,取前100各字符,MD5哈希成Base64格式,后缀一个"\r\n",最后用私钥签名。 哥们,你这九曲回肠的签名方法,把FBI都弄哭了!

6. Git日志几乎清一色的“优化”

以下是截取的最近的几条Git提交日志(git log --oneline | cat

e110a6da 优化
654db900 优化
1daba720 优化
20834312 优化缓存管理
f595b17f 优化
f4d9683d 修改bug
73593c9b 优化
ab0d465e 优化读取request.body
fb5f300c 优化
5e3d5e4d SQL
bf8a44d9 庆祝国人加入JCP
ad3b0047 庆祝国人加入JCP
603fb11f 庆祝国人加入JCP
ae7e968f 优化-庆祝国人加入JCP
c44d888c 优化
5228bce1 优化
4cd3257d 优化
7973d0b9 优化配置
6fb59931 优化配置
6393156c 优化配置
17de6d23 优化FDFS
c6bb4e70 优化
b5ea3662 JSTL
c619c366 省-市-区县
443a6b60 优化邮件模块
101c0f0c 优化发送邮件
0c87fd5c 优化
929d7a07 发送邮件
93c66a68 优化配置

不知道这位“Git优化大师”是在解释什么,还是在掩饰什么。。。

这iBase4J的槽点太多,我实在吐不过来了,JavaScript代码还没提到。软件写成这样,自己用就得了,还出来招摇撞骗、误人子弟就太过分了。

捐赠要钱、文档要钱、加群交流要钱、后台UI也要钱,能要钱的地方你一个都拉不下,哥们你想钱想疯了,还有心思写代码吗???

希望大家多多转载,多多评价,还开源界一片净土!

文章首发:https://baijifeilong.github.io/2018/07/03/ibase4j


漫漫路,莫论逍遥;潜心修,只为悟道