作者归档:衔山

绕过代理服务器获取客户端IP

一般来说,Web应用和客户端之间隔着很多个代理服务器,无论正向代理还是反向代理。这样就给本地资讯类应用(新闻、天气等)或者统计(审计,追踪等)需求带来了麻烦,因为在应用通过request.getRemoteAddr()方法(以Java为例)往往只能拿到与自己直接通信的设备IP。因此,如果用户通过直接或者间接的正向代理(ISP提供的缓存服务器等)上网,Web应用只会取到正向代理服务器地址;如果Web应用前端部署着Nginx或者Apache之类的反向代理,Web应用只会取到反向代理的地址。

面对这样的限制,代理厂商利用HTTP自定义Header规避了问题。比如Nginx,可以添加以下配置:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

它向后端应用传递了两个HTTP自定义Header,X-Real-IP和X-Forwarded-For。这两个Header作用类似,都是向后端透传客户端源IP,但是又有一些不同,主要区别在于:

1. X-Forwarded-For已有标准RFC文档(RFC 7239)定义,而X-Real-IP没有。
2. X-Forwarded-For Header格式为X-Forwarded-For: client1, proxy1, proxy2,每级代理都会将与自己直接通信的对端IP追加在X-Forwarded-For中,因此应用还需要额外解析IP列表以获取所需IP。而X-Real-IP只会记录与自己直接通信的对端IP。

由此,反向代理后面的应用服务器可以通过X-Real-IP或者X-Forwarded-For请求头来获取客户端IP:

String xRealIp = request.getHeader("X-Real-IP");
String xForwardIps = request.getHeader("X-Forwarded-For");

另外需要注意的是,HTTP Header是很容易被伪造的,因此,X-Forwarded-For Header中的首个IP也不意味着就一定是客户端源IP。从安全性方面来说,如果应用前不加反向代理,则request.getRemoteAddr()拿到的IP便是可信的,无法伪造;如果加了反向代理,那么X-Real-IP和X-Forwarded-For IP的最后一个IP是可信的,无法伪造。

--EOF--

增肥记

自成年以来,我身高一直在180-182CM间浮动,体重最重的时候大概是5年前的冬天了,大约65KG,平时则一直在62KG前后浮动。BMI指数18.7,处于正常和偏瘦之间。所以可以想象,我的体态应该属于偏竹竿的一类。

端午节出去买衣服时再一次发现自己太瘦了,于是也不知道是第几次了下定决心要增肥。之前的增肥计划因为各种原因都失败后,使得我一度怀疑自己是不是肠胃不好,因为我的饭量向来不小。后来在知乎看了一些问答,知道了热量、体重和增肌之间的关系,再回过头来想想,其实自己屡战屡败的原因只有一个:吃得不够多!体重变化这些事儿,一句话来概括就是当你每日摄入的卡路里大于消耗掉的,体重就会增加;反之,体重就会减少。

这里有每日所需热量的计算方式,以我为例:

增肥前体重:62.5KG
基础代谢率:1635 大卡/天
每日所需热量(职业码农,轻量运动):2534 大卡

那么如果需要增重,每日摄入的卡路里必须要达到 3000 - 3500 大卡。要知道一个鸡蛋大概70卡,一碗米饭才100卡,3000+大卡的热量意味着每天至少要吃5顿才能吃够。

于是接下来的一段时间,我每天的基础饮食就变成了这样:

  1. 早餐:600 - 800卡。
  2. 中餐:500 - 800卡。
  3. 下午点心:300 - 500卡。
  4. 晚餐:500 - 800卡。
  5. 夜宵:500 - 600卡。

这段时间,我看到的食物不是食物,而是流动的卡路里。当某一餐吃不够热量时,那就只能去售货机买面包、薯条之类的补补,也不管是不是垃圾食品了,总之坚决不能少吃。这里重点推荐一款国外应用FatSecret,它能量化每天摄入的热量,支持簿记体重和自动生成体重曲线,唯一的不足可能是中文食物种类较少,有时候吃到冷门食物时需要自己添加或者估算。每天卡路里摄入记录和体重曲线对每一个关心体重变化的人来说都非常重要,甚至我觉得是成败的关键。

吃是一方面,另一方面就是练。我没系统的健身计划,比较随意。基本上每周跑步4次,每次10分钟,1-2公里。跑完以后器材锻炼20分钟,主要项目是坐推3组,每组12个,练手臂力量和线条,仰卧起坐100个,分4-5组。

经过一个月的吃和练,体重从原先的62.5KG增加到70KG:

Weight History

体脂率总17.9增至20.2:
体脂率

遗憾的是,随着体重和脂肪的增加,身体年龄也从26岁增至31岁,而且明显感觉到肚子有些隆起,这说明练还是没有跟上吃的节奏,需下一阶段修正。
身体年龄

这次在一个月内完成增重的目标,最大的收获是自我感觉突破了身体极限,扭转了长久以来的惯性思维,证明自己的内心足够强大,这也算是一种认知上的提升吧。

--EOF--

Java IO Stream句柄泄露分析

Java io包封装了常用的I/O操作,流式操作被统一为InputStream、OutputStream、Reader、Writer四个抽象类,其中InputStream和OutputStream为字节流,Reader和Writer为字符流。流式抽象的好处在于程序员无需关心数据如何传输,只需按字节或字符依次操作即可。

在使用流式对象进行操作时,特别是文件操作,使用完毕后千万不能忘记调用流对象的close()方法,这也是老生常谈的话题,但是偏偏很容易忘记,或者没忘记但是使用不当导致close()方法没调用到。正确的做法是把close()方法放在finally块中调用,比如这样:

InputStream in = ...;
OutputStream out = ...;
try {
    doSth(in, out);
} catch (Exception e) {
    handleException();
} finally {
    try {
        in.close();
    } catch (Exception e1){
    }
    try {
        out.close();
    } catch (Exception e2){
    }
}

Java流式操作在便捷了I/O操作的同时,也带来了错误处理上复杂性,这也是Java被人诟病的理由之一。Golang在这块的处理就非常优雅,它提供了defer关键字来简化流程。

当文件流未被显式关闭时,会产生怎样的后果?结果就是会引起文件描述符(fd)耗尽。以下代码会打开一个文件10次,向系统申请了10个fd。

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    System.in.read(b); // pause
}

到/proc/{pid}/fd目录下确定已打开的文件描述符:

root@classa:/proc/16333/fd# ls -l
total 0
lrwx------ 1 root root 64 Aug  2 20:43 0 -> /dev/pts/3
lrwx------ 1 root root 64 Aug  2 20:43 1 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 10 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/ext/sunjce_provider.jar
lr-x------ 1 root root 64 Aug  2 20:43 11 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 12 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 13 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 14 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 15 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 16 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 17 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 18 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 19 -> /root/file
lrwx------ 1 root root 64 Aug  2 20:43 2 -> /dev/pts/3
lr-x------ 1 root root 64 Aug  2 20:43 20 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 3 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar

但是根据以往经验,即使流未显式关闭,也没见过文件描述符耗尽的情况。这是因为Java文件流式类做了保护措施,FileInputStream和FileOutputStream类利用Java的finalizer机制向GC注册了资源回收的回调函数,当GC回收对象时,实例对象的finalize()方法被调用。以FileInputStream为例,看看它是怎么处理的:

/**
 * Ensures that the close method of this file input stream is
 * called when there are no more references to it.
 *
 * @exception  IOException  if an I/O error occurs.
 * @see        java.io.FileInputStream#close()
 */
protected void finalize() throws IOException {
    if ((fd != null) &&  (fd != FileDescriptor.in)) {

        /*
         * Finalizer should not release the FileDescriptor if another
         * stream is still using it. If the user directly invokes
         * close() then the FileDescriptor is also released.
         */
        runningFinalize.set(Boolean.TRUE);
        try {
            close();
        } finally {
            runningFinalize.set(Boolean.FALSE);
        }
    }
}

当fd未释放时,finalize()方法会调用close()方法关闭文件描述符。有了这一层保障后,即使程序员粗心忘了关闭流,也能保证流最终会被正常关闭了。以下程序可以验证:

public static void main(String[] args) throws Exception {
    for (int i = 0; i < 10; i++) {
        InputStream is = new FileInputStream(new File("file"));
    }
    byte[] b = new byte[2];
    System.in.read(b); // pause1
    System.gc();
    System.in.read(b); // pause2
}

Java运行参数加上GC信息便于观察:

# java -verbose:gc -XX:+PrintGCDetails -Xloggc:gc.log -XX:+PrintGCTimeStamps StreamTest

程序在pause1处打开了10个fd,接着强制通过System.gc()触发一次GC,等gc.log中GC日志输出后再观察/proc/{pid}/fd目录,发现已打开的文件描述符均已经关闭。

但是即便如此,依然存在资源泄漏导致程序无法正常工作的情况,因为JVM规范并未对GC何时被唤起作要求,而对象的finalize()只有在其被回收时才触发一次,因此完全存在以下情况:在两次GC周期之间,文件描述符被耗尽!这个问题曾经在生产环境中出现过的,起因是某同事在原本只需加载一次的文件读取操作写成了每次用户请求加载一次,在一定的并发数下就导致too many open file的异常:

Exception in thread "main" java.io.FileNotFoundException: file (Too many open files)
        at java.io.FileInputStream.open(Native Method)
        at java.io.FileInputStream.(FileInputStream.java:146)
        ......

--EOF--

基于Redis的分布式锁的简易实现

本文讨论的分布式锁适用于集群节点程序的互斥执行,注意此处的“分布式”是指该锁的适用场景是分布式系统,而非锁本身是分布式的,关于锁本身的分布式设计可以参考Redis官方介绍:『Distributed locks with Redis』。

之前在『如何用Spring实现集群环境下的定时任务』一文中提到利用Redis的"GET/SET+TTL"机制实现集群场景下定时任务的互斥执行功能,这实际上就是本文实现的原型。该文描述的方法的问题在于GET和SET方法的组合并非原子操作,在多进程并行执行场景下可能有多个客户端获得锁,从而破坏了锁的安全性。

本文的改进在两个方面:

1. 要解决分布式锁的安全性问题,需要使用Redis提供的锁原语:SETNX(since 1.0.0)或者SET NX EX(since 2.6.12)。这类命令的语义是:如果Key已存在,则返回SET成功,否则返回失败。

2. 使用注解方式加锁和解锁,避免代码重复和耦合。

以下是Java实现(Spring AOP)的原型:

1. 定义注解类,接收expire参数,表示此锁的过期时间。如果在定时任务中使用,一般要大于节点间的时间差,小于定时任务的时间间隔。

@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface DLock {
    public String value() default "";

    public String expire() default "30";
}

2. 定义切面类,完成锁的申请和释放逻辑。

@Aspect
@Component
public class DLockAdvice {
    private static String DLOCK_PREFIX = "DLOCK_PREFIX_";

    @Autowired
    private RedisService redisService = null;

    @Around("@annotation(lock)")
    public Object lock(ProceedingJoinPoint pjp, DLock lock) throws Throwable {
        Long expire = Long.valueOf(lock.expire());;
        Signature sig = pjp.getSignature();
        String lockKey = DLOCK_PREFIX + sig.getDeclaringTypeName() + sig.getName() + expire;
        if (redisService.setValueIfAbsent(lockKey, true, expire)) {
            return pjp.proceed();
        } 
        return null;
    }
}

考虑到注解的通用性,锁名称的区分度越大越好,此处采用的“前缀+包名+类名+方法名+过期时间”,因此假如有个方法通过参数个数或者类型不同进行重载,则该锁会对这个重载的每个方法都生效,除非把参数个数或者类型信息加到锁名称里。

另外考虑到Redis服务本身或者网络的不稳定性,需要在RedisService的setValueIfAbsent()方法中对异常进行处理,假如:

1. 能够容忍多个客户端同时获得锁。那么当执行Redis命令异常时返回true。
2. 无法容忍多个客户端同时获得锁,宁愿没有客户端可以获得锁。那么当执行Redis命令异常时返回false。

--EOF--

Qmeta: RabbitMQ元数据备份工具

RabbitMQ的元数据泛指服务器端的Exchange、Binding、Queue、Vhost、Policy、账户、权限等定义信息。对元数据进行备份有两个好处:

1. 队列迁移。可以直接导入,不必所有信息重新输入。
2. 故障恢复。『单机磁盘故障引发RabbitMQ镜像队列数据丢失』一文中分析了镜像队列数据丢失的场景,如果元数据进行过备份的话,可以快速恢复服务。

RabbitMQ管理插件本身提供了元数据备份的API(GET /api/definitions),关键是如何高效地备份,特别是当手上有上百个RabbitMQ节点需要运维的时候。

Qmeta是我用Go写的一个RabbitMQ元数据备份小工具,它支持批量备份元数据,并以json的形式存储在备份目录下。

使用方式:
1. 构建

$ git clone git@github.com:fengchj/ftool.git
$ cd ftool/qmeta
$ go build 

2. 使用

$ qmeta -h
Usage of qmeta:
  -file="config": config file contains RabbitMQ node addrs.
$ cat config
127.0.0.1:15672 guest guest
10.20.30.40:15672 guest guest
$ qmeta -file config
Host 127.0.0.1 backup done!
Host 10.20.30.40 backup done!
$ cd qmeta_{time-in-second}
$ ls -l
127.0.0.1.json
10.20.30.40.json

简单介绍下Qmeta的实现:

1. flag包解析配置文件路径。
2. 每个节点的备份任务交给独立的goroutine处理。
3. net/http包发送HTTP GET请求,设置连接超时参数Timeout。
4. 所有goroutine共享http.Client实例,协程安全。
5. 使用带缓冲区的channel来实现主程序和goroutine的同步,当所有goroutine返回后,安全关闭channel。
6. 协程在defer语句中进行channel数据写入,以此来保证是否备份成功,goroutine都会返回。

--EOF--