路由器透明代理

作为程序员的都知道,现在比较流行的科学上网方式是$$。$$搭建步骤简单,速度快,相比其他的科学上网方式比较轻量。但这种方式也有一些缺点,比如需要在本机安装客户端,维护服务端信息等缺点,路由器透明代理就是为了解决这些问题而诞生的 。

传统科学上网实现方式

传统的科学上网方式是在本机安装$$客户端,不同平台的客户端如下:

  • IOS
    • ShadowRocket(国区已下架)
    • Surge(国区已下架)
  • Mac OS
    • Shadowsocks NG
    • Surge
    • ss-local
  • Linux
    • ss-local
  • Windows
    • Shadowsocks

代理模式

$$一般有两种代理模式,

  1. 自动切换模式

    • 黑名单模式,只代理被墙的网站,对于名单中匹配的网站进行代理,如果不匹配则直接连接,比较著名的 有GFW List
    • 白名单模式,默认代理,对于名单中匹配的网站进行直连。白名单模式在不同平台上的效果不同,在IOS可以实现白名单模式,在Mac OS 上只支持黑名单模式,如果要实现需要做一些hack操作,如:ShadowsocksX-NG实现白名单模式
  2. 全局模式

    所有流量都走代理,这个在不同平台上表现也不同,在Mac os上只能代理部分流量,像CLI的流量需要额外配置。

传统代理模式的缺点

传统的代理模式的缺点也很明显:

  1. 需要安装客户端,且需要在所有客户端配置服务器信息
  2. 无法代理系统级流量或需要额外设置
  3. 无法实现大陆白名单模式(除了大陆以外的网站都走代理)
  4. 被屏蔽的网站越来越多,所有客户端都需要更新名单

路由器透明代理

路由器透明代理,说直白点就是在路由器实现科学上网功能。路由器透明代理的优点很明显,无需在本机安装客户端,只需在路由器维护服务器信息及黑白名单,且能代理系统级流量。

数据流逻辑图

如下图所示, 路由器透明代理主要是两个流程,DNS解析流程与流量代理流程。
数据逻辑图

实现原理

路由器透明代理主要依赖如下工具:

  1. iptables
  2. ipset
  3. ss-redir
  4. ss-tunnel
  5. dnsmasq

dnsmasq+ss-tunnel

dnsmasqss-tunnel主要是用来解决域名污染的问题,dnsmasq会将国外域名dns解析请求通过ss-tunnel转发到shadowsocks服务器

dnsmasq配置文件:

https://cokebar.github.io/gfwlist2dnsmasq/dnsmasq_gfwlist.conf

1
2
3
4
5
server=/google.com/127.0.0.1#5353
server=/google.co.ma/127.0.0.1#5353
server=/google.com.af/127.0.0.1#5353
server=/google.com.ag/127.0.0.1#5353
server=/google.com.ai/127.0.0.1#5353

如上面的代码所示,遇到如需要解析google.com的域名时,dnsmasq会将解析请求转发到ss-tunnel监听的5353端口,ss-tunnel会将解析请求转发到远程服务器,通过远程服务器来解析域名。

iptables+ipset

iptablesipset主要用来区分国内流量和转发代理流量。

ipset

ipset.conf

1
2
3
4
5
1.0.1.0/24
1.0.2.0/23
1.0.8.0/21
1.0.32.0/19
...

shell:

1
2
ipset create chnroute hash:net
ipset -R < ipset.conf

上面代码创建了一个名为chnroute的IP集合。

iptables sample:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
iptables -t nat -N SHADOWSOCKS
iptables -t mangle -N SHADOWSOCKS

# 直连服务器 IP
iptables -t nat -A SHADOWSOCKS -d [SS-SERVER-IP]/24 -j RETURN

# 允许连接保留地址
iptables -t nat -A SHADOWSOCKS -d 0.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 10.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 127.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 169.254.0.0/16 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 172.16.0.0/12 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 192.168.0.0/16 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 224.0.0.0/4 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 240.0.0.0/4 -j RETURN

# 直连中国 IP
iptables -t nat -A SHADOWSOCKS -p tcp -m set --match-set chnroute dst -j RETURN
iptables -t nat -A SHADOWSOCKS -p icmp -m set --match-set chnroute dst -j RETURN

# 重定向到 ss-redir 端口
iptables -t nat -A SHADOWSOCKS -p tcp -j REDIRECT --to-ports 1080
iptables -t nat -A SHADOWSOCKS -p udp -j REDIRECT --to-ports 1080
iptables -t nat -A OUTPUT -p tcp -j SHADOWSOCKS

# Apply the rules
iptables -t nat -A PREROUTING -p tcp -j SHADOWSOCKS
iptables -t mangle -A PREROUTING -j SHADOWSOCKS

上面代码的主要含义是:

  • 先判断是不是请求远程$$服务流量,如果是则直接放行
  • 如果是请求内网的流量也会直接放行
  • 如果是请求大陆网站的流量,则直接放行
  • 如果上面的条件都不匹配,则会将流量转发到本机ss-local监听的1080端口

ss-redir

ss-redir会监听端口,并将代理请求转发到$$服务器

路由器透明代理实现方式

一般高端一点的路由器都可以通过刷机来实现,以华硕路由器为例:

梅林固件

梅林固件华硕路由器第三方固件,基于华硕官方固件。这种方式你需要自己搭建整套工具,虽然网上也有一些一键安装的脚本,但与我们上面提到的有一些出入。

官网: https://asuswrt.lostrealm.ca/

优点:开源,基于华硕官方固件,比较稳定。

缺点:

  • 需要熟悉linux
  • 需要自己搭建且坑比较多
  • 定制差
  • 除华硕路由器外,只支持部分非华硕路由器

梅林小宝

梅林小宝是基于梅林的固件,这个实现方式比较傻瓜式,刷机完成后可通过在软件中心安装shadowsocks,配置一下服务器信息就可以实现路由器透明代理。

官网:http://koolshare.cn/forum-96-1.html

优点:

  • 无需自己搭建
  • 有图形化管理界面

缺点:

  • 闭源,存在风险
  • 除华硕路由器外,只支持部分非华硕路由器

OpenWrt

OpenWrt是开源的路由器固件,他能提供一整套linux操作环境,插件众多且大多数插件都提供图形管理界面,大大降低而搭建难度。
当然在刷机之前你需要在Table of Hardware 里查一下你的路由器是否支持openwrt.

官网:https://openwrt.org/

优点:

  • 开源,维护者众多
  • 插件众多,很多定制功能:广告过滤,文件共享等。
  • 图形化管理界面
  • OpenWrt x86 支持X86平台,可实现软路由。

缺点:

  • 需要一点linux知识

路由器透明代理的不足

当然路由器透明代理也有其不足,路由器一般都是低功耗平台。以ac66u为例,大陆白名单模式下只能跑到30M的带宽,CPU占用已经是100%。

软路由

软路由顾名思义,就是在软件层面实现路由功能,基于x86平台,性能强大,解决硬路由性能不足的问题,这里就不多说了。

参考

利用shadowsocks打造局域网翻墙透明网关

dnsmasq gfwlist

China IP List

https://www.v2ex.com/t/503057

Debian jessie 安装 docker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sudo apt-get remove docker docker-engine docker.io
sudo apt-get update
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg2 \
software-properties-common
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
sudo apt-key fingerprint 0EBFCD88
sudo add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/debian \
$(lsb_release -cs) \
stable"
sudo apt-get update
sudo apt-get install docker-ce

Groovy JSON处理的一些细节

最近遇到一项目,需要在手机端存储用户数据,实现离线访问。其中用户数据处理的逻辑如下图:

User data flow

  1. 服务端从亚马逊S3上下载用户JSON文本数据库

  2. 反序列化用户数据

  3. 更新用户数据

  4. 将用户数据序列化为JSON文本

  5. 保存到亚马逊S3上

由于项目设计缺陷,用户所有的数据都存储一个Map对象里,导致Map对象过大,在项目运行过程中出现了内存不足的异常。为了解决内存不足问题,服务端采用了JacksonStreamingApi优化了JSON序列及反序列化步骤,避免将整个用户文件载入到内存中,至此内存不足的异常就再也没有发生。

其实这里有个问题,如果是用户数据过大,内存不足异常会在步骤3结束后就会发生,为什么偏偏在步骤4序列化为JSON时抛出呢?这里就要说到 Groovy的LazyMap了。

LazyMap代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class LazyMap extends AbstractMap<String, Object> {

static final String JDK_MAP_ALTHASHING_SYSPROP = System.getProperty("jdk.map.althashing.threshold");

/* Holds the actual map that will be lazily created. */
private Map<String, Object> map;
/* The size of the map. */
private int size;
/* The keys stored in the map. */
private String[] keys;
/* The values stored in the map. */
private Object[] values;

public LazyMap() {
keys = new String[5];
values = new Object[5];
}
...

public Object put(String key, Object value) {
if (map == null) {
for (int i = 0; i < size; i++) {
String curKey = keys[i];
if ((key == null && curKey == null)
|| (key != null && key.equals(curKey))) {
Object val = values[i];
keys[i] = key;
values[i] = value;
return val;
}
}
keys[size] = key;
values[size] = value;
size++;
if (size == keys.length) {
keys = grow(keys);
values = grow(values);
}
return null;
} else {
return map.put(key, value);
}
}

public Object get(Object key) {
buildIfNeeded();
return map.get(key);
}

private void buildIfNeeded() {
if (map == null) {
// added to avoid hash collision attack
if (Sys.is1_8OrLater() || (Sys.is1_7() && JDK_MAP_ALTHASHING_SYSPROP != null)) {
map = new LinkedHashMap<String, Object>(size, 0.01f);
} else {
map = new TreeMap<String, Object>();
}

for (int index = 0; index < size; index++) {
map.put(keys[index], values[index]);
}
this.keys = null;
this.values = null;
}
}
}

从代码中可以看出:对于未进行过读操作(get,containsKey等)的LazyMap对象,keys和values分别存在了两个数组中,一旦调用了读取方法,LazyMap会将数组转化成Map对象,就是这一步操作引起了内存占用变化。

拿大小约为35MB的用户文件测试,反序列化后,内存中LazyMap对象为73MB(line 1),一旦对对象进行toJson操作(line 2),内存占用上升到了559MB(line 3)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    public static void main(String[] args) {
File jsonFile = new File('data-format.json')
Object obj = new JsonSlurper().parse(jsonFile) // LazyMap here
showSize(obj) // line 1
JsonOutput.toJson(obj) // line 2
showSize(obj) // line 3
}

static void showSize(obj) {
def size = ObjectSizeCalculator.getObjectSize(obj) / 1024 / 1024
println("Object memory size is $size MB")
}

// output logs:
Object memory size is 73.51363372802734375 MB
Object memory size is 559.32244873046875 MB

由于用户数据文件较大且嵌套了多层Map,加之JsonOutput.toJson方法会遍历LazyMap对象所有节点,相当于对所有节点进行了读操作,导致节点中的数组转换成Map对象,最终引起内存不足异常。

ASUS 66u 搭建透明代理

安装软件

1
2
3
4
5
6
7
8
9
10
/usr/sbin/entware-setup.sh

opkg install bind-dig
opkg install shadowsocks-libev-ss-redir
opkg install shadowsocks-libev-ss-tunnel
opkg install libc libssp libev libmbedtls libpcre libpthread libsodium haveged zlib libopenssl
opkg install ipset4

//ac66u only
opkg install iptables

配置ss-redir服务

修改配置文件

  1. vim /opt/etc/init.d/S22shadowsocks
  2. ss-local改为ss-redir
  3. 添加启动参数-b 0.0.0.0
  4. ENABLED值改为 yes,变成开机启动

修改shadowsocks.json

修改服务器IP,端口,加密码方式。

配置dnsmasq

创建配置文件

  • vim /jffs/configs/dnsmasq.conf.add
  • 添加conf-dir=/opt/etc/dnsmasq.d

创建目录

  • mkdir /opt/etc/dnsmasq.d/

  • 下载规则文件

1
2
cd /opt/etc/dnsmasq.d/
curl -O https://cokebar.github.io/gfwlist2dnsmasq/dnsmasq_gfwlist.conf

配置ss-tunnel服务

ss-tunnel主要用于解决dns污染的问题,dnsmasq会将解析请求转发到ss-tunnel

创建开机启动脚本

  • 转到目录/jffs/scripts/
  • 创建名为比如start-ss-tunnel.sh文件,内容如下:
1
2
3
#!/bin/sh

/opt/bin/ss-tunnel -c /tmp/mnt/onmp/entware/etc/shadowsocks.json -l 5353 -L 8.8.8.8:53 -u > /dev/null 2>&1 &

创建service-start文件

1
chmod +x service-start

/jffs/scripts/start-ss-tunnel.sh 加到service-start

至此ss-tunnel就能开机启动了

配置iptables

下载chnroute.txt

1
wget -O- 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest' | awk -F\| '/CN\|ipv4/ { printf("%s/%d\n", $4, 32-log($5)/log(2)) }' > /jffs/configs/chnroute.txt

生成ipset集合

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh

wget -O- 'http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest' | awk -F\| '/CN\|ipv4/ { printf("%s/%d\n", $4, 32-log($5)/log(2)) }' > /jffs/configs/chnroute.txt

ipset -N chnroute nethash

for ip in $(cat '/jffs/configs/chnroute.txt'); do
ipset -A chnroute $ip
done


ipset --save > /jffs/configs/ipset.conf

因为chnroute.txt文件比较大,生成会比较慢,故将生成的ipset保存到ipset.conf中,每次启动时从ipset.conf中导入IP.

启动脚本

SS启动脚本ss-up.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#!/bin/sh

alias iptables='/opt/sbin/iptables'

ipset -R < /jffs/configs/ipset.conf

# ipset -N chnroute iphash

# for ip in $(cat '/jffs/scripts/chnroute.txt'); do
# ipset -A chnroute $ip
# done

iptables -t nat -N SHADOWSOCKS
iptables -t mangle -N SHADOWSOCKS

# 直连服务器 IP
iptables -t nat -A SHADOWSOCKS -d [SS-SERVER-IP]/24 -j RETURN

# 允许连接保留地址
iptables -t nat -A SHADOWSOCKS -d 0.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 10.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 127.0.0.0/8 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 169.254.0.0/16 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 172.16.0.0/12 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 192.168.0.0/16 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 224.0.0.0/4 -j RETURN
iptables -t nat -A SHADOWSOCKS -d 240.0.0.0/4 -j RETURN

# 直连中国 IP
iptables -t nat -A SHADOWSOCKS -p tcp -m set --match-set chnroute dst -j RETURN
iptables -t nat -A SHADOWSOCKS -p icmp -m set --match-set chnroute dst -j RETURN

# 重定向到 ss-redir 端口
iptables -t nat -A SHADOWSOCKS -p tcp -j REDIRECT --to-ports 1080
iptables -t nat -A SHADOWSOCKS -p udp -j REDIRECT --to-ports 1080
iptables -t nat -A OUTPUT -p tcp -j SHADOWSOCKS

# Apply the rules
iptables -t nat -A PREROUTING -p tcp -j SHADOWSOCKS
iptables -t mangle -A PREROUTING -j SHADOWSOCKS

将其中的SS-SERVER-IP修改成你的服务器地址

创建post-mount文件

1
chmod +x post-mount

ss-up.sh加入

1
2
#!/bin/sh
/jffs/scripts/ss-up.sh

经测试只有将启动脚本加入到post-mount中对能开机启动,可能的原因是ss-up.sh中设及到挂载分区的访问。

停止脚本

1
2
3
4
5
6
#!/bin/sh
iptables -t nat -D OUTPUT -p icmp -j SHADOWSOCKS
iptables -t nat -D OUTPUT -p tcp -j SHADOWSOCKS
iptables -t nat -F SHADOWSOCKS
iptables -t nat -X SHADOWSOCKS
ipset -X chnroute

参考

ShadowsocksX-NG实现白名单模式

ShadowsocksX-NG Proxy Auto Configure Mode使用的是黑名单模式,默认直连,符合规则的走代理。要实现白名单模式,默认代理,符合规则的直连,有一种hack方法:

  • 找到程序文件夹下的abp.js文件,路径一般为/Applications/ShadowsocksX-NG.app/Contents/Resources/abp.js

  • 修改FindProxyForURL方法,将direct,proxy对调,改成下面代码中的样子

    1
    2
    3
    4
    5
    6
    function FindProxyForURL(url, host) {
    if (defaultMatcher.matchesAny(url, host) instanceof BlockingFilter) {
    return direct;
    }
    return proxy;
    }
  • 重启程序

  • 修改程序preferences里的GFW List url,改成白名单规则
  • 然后点击Update PAC from GFW List更新pac文件即可

Groovy+Grails迁移到Kotlin+Spring Boot的一些研究

Kotlin是静态语言,可以运行在JVM上且与java百分百可互操作。自从Google将Kotlin正式做为Android开发的语言后,Kotlin的一下子就火了起来。Kotlin不仅可以做为Android的首选开发语言,而且它也非常适合服务端的开发。

为什么选择Kotlin

与Java百分百可互操作groovy-grails-to-kotlin-springbootgroovy-grails-to-kotlin-springboot

Kotlin可以使用JVM平台上的任何库与框架,Kotlin编写的代码也可以供Java调用

简洁

Kotlin的代码非常简洁:

变量

变量声明:

1
2
3
4
val a: Int = 1  // immediate assignment
val b = 2 // `Int` type is inferred
val c: Int // Type required when no initializer is provided
c = 3 // deferred assignment

可变变量:

1
2
var x = 5 // `Int` type is inferred
x += 1

可空值与不可空值:

1
2
val s: String? = null //May be null
val s2: String = "" //May not be null

方法

1
2
3
4
5
6
fun sum(a: Int, b: Int): Int {
return a + b
}

//Function with an expression body and inferred return type:
fun sum(a: Int, b: Int) = a + b

扩展函数

1
2
3
4
fun String.lastChar(): Char = this.get(this.length - 1)

>>> println("Kotlin".lastChar())
n

infix函数

1
2
3
infix fun Any.to(other: Any) = Pair(this, other)

val (number, name) = 1 to "one"

1
2
3
4
5
6
7
8
9
10
11
class Greeter(val name: String) {
fun greet() {
println("Hello, $name")
}
}

fun main(args: Array<String>) {
val greeter = Greeter("John")
greeter.greet()
println(greeter.name)
}

数据类(data class)

自带equals hashcode tostring copy等方法

1
data class User(val name: String, val age: Int)

DSL(Domain-specific language)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
open class Tag(val name: String) {
private val children = mutableListOf<Tag>()

protected fun <T : Tag> doInit(child: T, init: T.() -> Unit) {
child.init()
children.add(child)
}

override fun toString() = "<$name>${children.joinToString("")}</$name>"
}

class TABLE : Tag("table") {
fun tr(init: TR.() -> Unit) = doInit(TR(), init)
}

class TR : Tag("tr") {
fun td(init: TD.() -> Unit) = doInit(TD(), init)
}

class TD : Tag("td")

fun table(init: TABLE.() -> Unit) = TABLE().apply(init)

fun createTable() = table {
tr {
td {

}
}
}

>>> println(createTable())
<table><tr><td></td></tr></table>

空安全

空安全(null safe)可以说是kotlin杀手级的特性了,它能排除一切空指针的可能,让你写出更安全的代码。

如下面这段代码会直接编译不通过:

1
2
var output: String
output = null // Compilation error
1
2
val name: String? = null    // Nullable type
println(name.length()) // Compilation error

自动类型转换:

1
2
3
4
fun calculateTotal(obj: Any) {
if (obj is Invoice)
obj.calculateTotal()
}

静态类型(Statically Typed)

动态语言是一把双刃剑,groovy的代码非常简洁,弥补了java语言太啰嗦的缺点,同时groovy的闭包也非常好,正因为如此我们选择了groovy。但时做为动态语言,许多行为是在代码运行时才会确定,编译错误在写代码时无法检测到,除非加上@TypeChecked注解。

Kotlin的代码非常简洁,更重要的是它是静态类型语言,能检测到编译错误,减少了代码中潜在的问题。

为什么选择Spring Boot

  • 从2015年初开始,Sring Boot的热度就超过了Grails,可参考:Grails && Spring Boot Stack Overflow Trends
  • Grails 2.x版本有上千个插件,但升级到Grails 3.x之后,这些插件就不再被支持,更糟糕的是没有人将这些插件迁移到Grails 3.x,Grails 3.x现在只有244个插件。
  • 尽管Grails 3是基于Spring Boot的,但其自身也带来了些奇怪的bug, 有时候解决框架bug的时间甚至超过了使用框架节省的时间。

Kotlin+Spring Boot的一些不足

没有Gorm查询

Grails框架的最大亮点是Gorm:动态方法和DSL查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
//will be translated to hql automatically
def person = Person.findByFirstName('xxx')
equals:
"FROM Person WHERE firstname = ?"

Person.withCretia {
or {
ilike('name','%j%')
gte('age',22)
}
}
equals:
"FROM person WHERE name ilike ? OR age >= ?"

在Spring Boot中你只能自己写查询语句或者使用类似spring data jpa query dsl插件:

1
2
3
4
5
6
internal interface TodoRepository : Repository<Todo, Long> {

fun findFirst3ByTitleOrderByTitleAsc(title: String): List<Todo>

fun findTop3ByTitleOrderByTitleAsc(title: String): List<Todo>
}

数据库迁移(Database Migration)

Grails 的database migration在数据库表结构和数据迁移两方面都非常强,它可以对比项目中的实体和数据库表来自动生成表结构修改语句,同时你可以写sql来完成数据迁移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
changeSet(author: "xxx (generated)", id: "1521527665533-1") {
addColumn(tableName: "staff") {
column(name: "date_created", type: "TIMESTAMP WITHOUT TIME ZONE") {
constraints(nullable: "true")
}
}
}

changeSet(author: "xxxx", id: "1521528248") {
grailsChange {
change {
sql.execute('''
UPDATE staff
SET date_created = now()
''')
}
}
}

而在Spring Boot方面,数据库迁移插件有flyway和liquibase,两者各有千秋,flyway在数据迁移上比较强,但缺少自动生成表结构修改语句功能。liquibase正好相反,它有自动生成表结构修改语句功能,但在写数据迁移sql方面比较弱。

表单校验

在自定义校验方法上Grails非常方便,只需要一个闭句就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
class User {
String name
static constraints = {
name unique: true, validator: { val, obj ->

if (!val.matches('[A-Z0-9-]+')) {
return 'acronym.format.error'
}

true
}
}
}

而在Spring Boot方面比如用hiberate validator你需要定义类,定义注解,写很多代码来实现自定义校验。

总结

在项目开发速度上,Groovy+Grails上占优,如果时间比较紧的话,Groovy+Grails会是一个不错的选择,它能在较短时间内做出项目。如果你需要一个bug少,更稳定的项目或者你的项目用不到Gorm的话,你可以选择Kotlin+Spring Boot.

简而言之,100分做为满分的话,Groovy+Grails能在最短的时间内达到80分,而要达到90甚至95分的话,选择Kotlin+Spring Boot无疑。

Amazon API Gateway 踩坑记

最近项目中用到了Amazon API Gateway,当中遇到了不少的坑。本文主要罗列了在使用amazon api gateway中遇的问题,希望能帮助到遇到相同问题的开发者。

不支持parmeters array

1
GET /user/1/addresses?addressId=1&addressId=2

服务端接收到的只有一个值,解决办法passing-array-query-parameters-with-api-gateway-to-lambda

POST/PUT 返回 406

当使用json body发送put/post请求时,api gateway有一定机率返回406,查了半天也找出什么原国,后来在stackoverflow上找到了答案,需要在Integration Request中添加Accept头,并且设置其值为空字符串。具体什么原因估计只有AWS api gateway的开发者才知道了。
aws-api-gateway-returns-http-406

URL Query String Parameter、header需要显示设定

如果不在Method Request里显示声名URL Query String Parameter的话,请求参数是不会转发到后台的,request header同理。

Grails参数绑定不支持application/x-amz-json-1.0/application/x-amz-json-1.0 content type.

请求转发到后台时,API gateway会将application/json转换成application/x-amz-json-1.0/application/x-amz-json-1.0,需要修改代码,添加对这两个header的支持,不然会无法绑定参数。

Java 浮点数存储方式

Java浮点数使用的是IEEE二进制浮点数算术标准。

浮点数分为符号,指数,分数三部分。以32位浮点数为例,分为1符号位+8指数位+23分数位。

1(bit) 8(bit) 23(bit)
符号 指数 分数

以173.7为例:

173.7为正数,故符号位为0

0 00000000 00000000000000000000000

将其转换成1.??? * 2^?形式

1
2
3
4
5
6
7
173.7/2=86.85            2^1
86.85/2=43.425 2^2
43.425/2=21.7125 2^3
21.7125/2=10.85625 2^4
10.85625/2=5.428125 2^5
5.428125/2=2.7140625 2^6
2.7140625/2=1.35703125 2^7

最终格式为1.35703125*2^7 ,指数位的值为7,由于指数部分是由Bias偏移量来表示的,float的偏移量为Bias=2^k-1 -1=2^8-1 -1=127,做指数部分的值为127+7=134,134的二进制值为 1000 0110,则符号位+指数为存储格式如下:

0 1000 0110 00000000000000000000000

计算尾数部分,由于浮点数最终都是以1.??? * 2^?的形式保存的,小数点左边永远是1,所以我们只需关心小数点后面部分

Number X2 1/0
0.35703125 0.7140625 0
0.7140625 1.428125 1
0.428125 0.85625 0
0.85625 1.7125 1
0.7125 1.425 1
0.425 0.85 0
0.85 1.7 1
0.7 1.4 1
0.4 0.8 0
0.8 1.6 1
0.6 1.2 1
0.2 0.4 0
0.4 0.8 0

由于到了0.4这一步这边是会个循环,0110 0110 …
加上尾数部分,最终存储格式如下:
0 1000 0110 01011011011001100110011

我们可以通过Float.floatToIntBits(173.7)来验证结果:

1
2
3
Float.floatToIntBits(173.7) = 1127068467
1127068467用二进制表示,就是
0 10000110 01011011011001100110011

Grails 3.2.11 Bugs

  • Functional Testing setup无法回滚
1
2
3
4
5
6
7
8
9
10
class BaseFunctionalTest extends GebSpec {
def setup() {
Dog dog = new Dog(name:'xxxx')
}

//需要手动删除
def cleanup() {
Dog.findByName('xxxx')?.delete()
}
}
  • JSON Views循环渲染bug

如果多个GORM实体存在循环引用,则会产生stackover flow异常,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
Class A {
static hasMany = [children: B]
}

Class B {
A parent
}

def a = new A()
def b = new B(parent:a)
a.addToChildren(b)

json g.render(a, [deep: true])
  • isDirty方法实际上时通过检测类属性指向的引用是否改变来实现的,假如是属性内部的属性发生改变,isDirty方法实际是检测不到的。如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person{
String firstName
String lastName
Address address
}

class Address{
String city
String street
}

Address address = new Address('shanghai','street')
Person person = new Person('fname','lname',address)

person.save()
person.firstName = 'xxxx'
person.isDirty() == true
person.isDirty('firstName') == true

person.save()
address.city = 'beijing'
person.isDirty('address') == false
person.isDirty() == false

person.address.isDirty() == true
person.address.isDirty('city') == true
  • build test data plugin 不支持自定义约束的属性,就算在TestDataConfig.groovy中设置了也没用,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
testDataConfig {
sampleData {
Dog {
name = { ->
UUID.randomUUID().toString().toUpperCase().replace('-', '')
}
}
}
}

Class Dog {
String owner
String name

static constraints = {
name validator: { val, obj ->
...
}
}
}

Dog dog = Dog.build()
dog.name == 'name'
  • one-to-many关联中,用left join的方式从many的一方查找出来one,one关联的many对象不正确,如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Person {
static hasMany = [dogs:Dog]
}

class Dog {
String name
}

Dog a = new Dog('A')
Dog b = new Dog('B')

Person p = new Person('P')
person.addToDogs(a)
person.addToDogs(b)
person.save()

Collection<Person> result = Person.withCriteria {
createAlias('dogs', 'd', org.hibernate.sql.JoinType.LEFT_OUTER_JOIN)
eq('d.name', 'A')
}

result[0].dogs.size() == 1

result = Person.withCriteria {
dogs {
eq('name','A')
}
}

result[0].dogs.size() == 2

Spring RestTemplate 打印Request及Response内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import org.apache.commons.io.IOUtils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.http.HttpRequest
import org.springframework.http.client.ClientHttpRequestExecution
import org.springframework.http.client.ClientHttpRequestInterceptor
import org.springframework.http.client.ClientHttpResponse


class LoggingRequestInterceptor implements ClientHttpRequestInterceptor {

static final String DEFAULT_ENCODING = 'UTF-8'
static final Logger LOGGER = LoggerFactory.getLogger(LoggingRequestInterceptor)

@Override
ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
traceRequest(request, body)
ClientHttpResponse response = execution.execute(request, body)
traceResponse(response)
return response
}

private void traceRequest(HttpRequest request, byte[] body) throws IOException {
LOGGER.info('===========================request begin================================================')
LOGGER.info('URI : {}', request.URI)
LOGGER.info('Method : {}', request.method)
LOGGER.info('Headers : {}', request.headers)
LOGGER.info('Request body: {}', new String(body, 'UTF-8'))
LOGGER.info('==========================request end================================================')
}

private void traceResponse(ClientHttpResponse response) throws IOException {
LOGGER.info('============================response begin==========================================')
LOGGER.info('Status code : {}', response.statusCode)
LOGGER.info('Status text : {}', response.statusText)
LOGGER.info('Headers : {}', response.headers)
LOGGER.info('Response body: {}', IOUtils.toString(response.body, DEFAULT_ENCODING))
LOGGER.info('=======================response end=================================================')
}

}
  • 配置RestTemplate
1
2
3
RestTemplate restTemplate = new RestTemplate()
restTemplate.setRequestFactory(new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
restTemplate.setInterceptors([new LoggingRequestInterceptor()])

由于会多次读取request及response body,所以我们这里会使用BufferingClientHttpRequestFactory类来保证能够读取多次。