彭琪谈编程

iOS Routing

昨天看了篇博客讨论iOS 组件化方案探索,很受启发,说一下我的几点看法。

起源

这次讨论起源于蘑菇街App的组件化方案,组件间的耦合比较乱,所有功能放到一个大项目里开发的效率也比较低。将App拆成几个大的组件(受React影响,我认为component的粒度很小,叫模块Module可能更加合适),将工程师(估计不少了)拆成几个独立的小组,分别专注于自己组件的开发(提高编译速度),再通过持续集成合并各个小组的代码打包成最终App,是大势所趋,我相信也是所有大公司的普遍做法。

这套方案牵涉到一个组件间通信的问题,包括但不限于以下几种形式:

  • 从组件A进入到组件B
  • 从组件B获取状态,显示在组件A
  • 组件A修改组件B的状态

蘑菇街提供了一个叫做Router的中间件来封装这样的通信,这样每个组件只需要和这个Router交互就行了。后面加入的讨论也是针对这个Router是该使用url、category还是Target-Action的方式展开。

我的看法是,这些方式并没有解决本质问题,即组件间的耦合。但是

这里真的需要解耦吗?

组件间的通信是基于业务上的需求,当组件A肯定需要和组件B通信的时候,他们是直接通信还是通过中间件通信,有什么本质区别呢?他们在业务上是紧密耦合的,是肯定会互相依赖的(比如列表页肯定依赖于详情页)。

组件A不光知道组件B的存在,还知道该调用它的哪个方法,传哪些参数,我都知道这些了,还需要中间件干嘛呢?

而且这种做法会增加额外的维护成本。当组件B的公共接口变化了(比如加了个参数),以前我只需要修改组件A就行了,现在呢,不光要改下组件A(这个参数是要实际的使用者传过来的),还得改下中间件(“转发”下新增的参数),不嫌麻烦么?

另外,这样的修改和你具体使用哪种形式的中间件(url、category、target-action)无关,一旦发生类似的变化,你都要多修改一处代码。

所以,我的看法是静态的组件间不存在解耦,也不需要中间件

那么不需要组件化了?

组件话肯定是有好处的,但它的好处主要在于工程层面,能将大团队拆分成小组,独立开发,互不干涉,提高协作效率。就像使用React时,你专注于每个component的编写,不用关心其他的component。

这里需要改善的其实是每个组件的公共接口,让其尽量简洁明了,统一规范,降低小组间的沟通成本。

就像React的component间能自由组合,只要传入声明的props就好,这里的组件可以理解为每个小组生成的pod或者framework,我只要看下头文件,就知道该怎么用了。

那么Router就完全没用了?

Router的最大好处,在于动态调用。

组件A不需要知道组件B的存在,甚至它可以在运行时和任何组件通信,这个需求是由服务器传来的或者是其他业务代码动态决定的,我不关心它是具体哪个组件我也不关心它需要哪些参数,我只管把这个需求传给一个Router,它会负责处理。

这里的Router要做得足够健壮,处理好容错和版本兼容性等问题,也要避免Router和各个组件形成依赖,它只负责动态转发,被调用者自己来决定是否能响应具体的请求,这里可能用一个protocol来做好约束比较合适。

Router适合的场景就不用多说了,运营驱动型的产品对此是刚需,一个hybrid页面,通过router能自由跳转到各个组件,这样的灵活性是必须的。

MVC和Component

经典MVC

MVC是做客户端开发的工程师非常熟悉的一个设计模式,然而很多人只是把它理解为程序的三个模块(数据、界面、控制器),针对的优化建议也是把这三个模块(尤其是控制器)尽量精简,把不相关的代码(比如网络请求)封装出去。这些建议是没错的,但是忽略了MVC非常重要的另一面——它其实是一个由三个设计模式组成的复合模式,分别用于这三个模块之间的通信。

这里的核心是通信。很多人可能会有类似的体验,当产品越做越大后,扩展会变得越来越慢。当然这里牵扯到的原因很多,但其中很重要的一点就是,当我们修改了某些状态后,要更新的界面越来越多,同页面的跨页面的,一大堆调用。这是一个很典型的问题,早在MVC被发明的时候,它就给出了很好的建议——观察者模式。在设计模式这本书中,1.2节就介绍了Smalltalk是怎样解决这个问题的:

MVC通过建立一个“订购/通知”协议来分离视图和模型。视图必须保证它的显示正确地反映了模型的状态。一旦模型的数据发生变化,模型将通知有关的视图,每个视图相应地得到刷新自己的机会。这种方法可以让你为一个模型提供不同的多个视图表现形式,也能够为一个模型创建新的视图而无须重写模型。

很多iOS工程师其实都知道这个模式,但是并没有怎么应用,一是因为模块不够清晰,很多Controller直接画View,因此也直接代劳了View的更新,二是苹果提供的KVO有些坑,用得不好容易导致应用崩溃(比如Observee提前释放,Observer在销毁前必须取消订阅),这里推荐Facebook的KVCController,能让KVO安全不少。

MVC中应用的第二个模式是Composite(组合模式),这个模式其实没有啥好讲的,SDK层已经这么做了。你使用的View可能是一个独立的控件,也可能是一个由很多控件组成的复杂界面,但是他们都可以当成一个普通的View来处理。如今应该没有哪个平台的SDK不是这么做的。

MVC中应用的第三个模式是Strategy(策略模式),它解决的是View和Controller之间的通信(响应用户的输入)。我们还是继续看下Smalltalk是怎样描述这个问题的。

View-Controller关系是Strategy模式的一个例子。一个策略是一个表述算法的对象。当你想静态或动态地替换一个算法,或你有很多不同的算法,或算法中包含你想封装的复杂数据结构,这时策略模式是非常有用的。

View和Controller之间是完全独立的,我们应该尽可能多的使用xib或UIView的子类来画界面,保证其复用性,Controller只负责响应其交互,并根据不同的场景调整策略。比如说头像,它本身是一个View,并可能有显示VIP等级等特性,但是点击头像这一交互,就有跳转到Profile或者查看大图两种策略。

在苹果的文档中,也提到了这三者之间的关系,如下图所示。

traditional_mvc

Cocoa MVC

然而苹果并不推荐以上这种模式,因为View和Model绑定在一起了,他们推荐下图这种方式。

cocoa_mvc

这样做的目的是为了斩断View和Model之间的依赖,提高各自的重用性。苹果提供了一个中介(Mediating Controller)将两者联系起来,并提供了一系列的Binding技术来减少胶水代码,这个过程甚至可以直接在Interface Builder中完成。

Cocoa SDK里有两种Controller,除了上面说的Mediating Controller,还有一种是Coordinating Controller,它是页面的骨架,可以包含多个Mediating Controller。一个完整的界面就是由多层次的Controller共同组成,各自负责一小块UI的交互响应。

不过可惜的是,在iOS开发中,苹果并没有提供这种技术,可能是因为手机屏幕很小,不会有太复杂的界面吧。

Components

Cocoa MVC给了我不少启发,一个界面并不是只能有一个Controller,它可以由多个Component组成,每个Component都拥有一个内部的MVC结构。这样一个Component可以多处复用,代码结构也更加清晰。

苹果从iOS 5就开始支持自定义Container Controller,childController可以访问parentController,还可以访问最外层的navigationController、tabBarController等等,这就能让我们的childController控制页面的跳转。

这个技术可以用来做一些高度复用的Component,把部分UI和响应策略全部封装起来,比如上面说的点击头像推到Profile页面,或者点击头像查看大图。这样两个头像Component能覆盖绝大多数场景,可以节省不少代码,而且易于维护,他们的View是重用的,不同的只是Controller。

除此以外,childController还能接收到viewWillAppear、viewWillDisappear等事件,让其做些初始化准备或动画效果。

最新的3D Touch事件也可以加在childController上,使得扩展新的交互方式非常方便,比如头像Component加好后,任何地方的头像都可以支持Peek and Pop。

这样一种层次化的MVC并不是苹果的专利,安卓里一个Activity(最外层的Controller)可以由多个Fragment(childController)组成,Fragment能接受各种UI Event,也能发起Intent,本质上是一样的。

Child Controller

使用child controller其实很简单,四行代码:

1
2
3
4
[self addChildViewController:childViewController];
childViewController.view.frame = [self frameForChildController];
[self.view addSubview:childViewController.view];
[childViewController didMoveToParentViewController:self];

第一行会让childViewController收到willMoveToParentViewController:事件。

删除Child Controller也分成三步:

1
2
3
[childViewController willMoveToParentViewController:nil];
[childViewController.view removeFromSuperview];
[childViewController removeFromParentViewController];

最后一行会让childViewController收到didMoveToParentViewController:事件。

这么做的目的是让childViewController在被加载和删除前后都能收到通知。

Cell Component

如果UITableViewCell是一个比较复杂的页面,有多种交互,还不只用于一个TableView,那么可以把它的界面单独抽出来,和Controller组成一个Component,包裹在一个容器Cell中。

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
  - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
  {
      ContainerCell *cell = [tableView dequeueReusableCellWithIdentifier:@"containerCell"];
      if (!cell) {
          cell = [[ContainerCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"containerCell"];
          cell.contentVC = [[UserCellController alloc] init];
      }
      cell.contentVC.user = user;
  }

  - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath
  {
      [(ContainerCell *cell) addToParentVC:self];
  }

  - (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath
  {
      [(ContainerCell *)cell removeFromParentVC];
  }

@interface ContainerCell()

  - (void)addToParentVC:self:(UIViewController *)parentVC {
      [parentVC addChildViewController:self.contentVC];
      self.contentVC.frame = self.contentView.bounds;
      [self.contentView addSubview:self.contentVC.view];
      [self.contentVC didMoveToParentViewController:parentVC];
  }

  - (void)removeFromParentVC {
      [self.contentVC willMoveToParentViewController:nil];
      [self.contentVC.view removeFromSuperview];
      [self.contentVC removeFromParentViewController];
  }

这种方式可以让Cell中的Controller接收到ViewDidAppear等各种UI Event。

React

React是Facebook开源的一个特别火的JS框架,虽然它声称自己只是MVC中V,但我觉得它其实是一个微型的MVC:

  • props、state对应Model,当他们有变化时框架会自动刷新UI
  • Class对应Controller,提供componentDidMount、componentDidUpdate等UI Event,并且响应用户的交互
  • render方法对应View

React的优势在于,它完全是声明式(Declarative)的语言。

  • 通过JSX描述UI,并可以嵌套其他自定义Class
  • 自动侦听props和states的变化,当需要更新UI时,会直接调用render方法来重绘,不用你写任何胶水代码
  • 使用Virtual Dom来渲染,提高性能

工程师不用在意UI最后是如何生存的,只关注于描述UI本身。这样做不仅能节省大量代码,提高写UI的效率,而且会让你主动去用Component的思路拆分UI,养成复用的好习惯。

它也能让开发者专注于自己写的Component,Component之间互不干涉,没有副作用。

ComponentKit & React Native

Facebook的iOS团队借鉴了React的思路,也做了套类似的ComponentKit,为了尽可能的模仿React的写法,它用了Objective C++,但是它只适用于Collection View和Table View,可以说是专门为Facebook App里的Feed流而设计的框架吧,用的人并不多。因此,为了更广泛地应用React的思想,Facebook又开发了React Native,支持各种UI控件,写法和React一模一样。

React Native让很多Web开发者得以开发iOS和Android程序,但是对iOS和Android程序员帮助不大,除非他们愿意学习web开发。但是React的思路是完全可以借鉴的。在我看来,它就是对Cocoa MVC框架的再封装。用Component开发组件,用声明式的语言提高开发效率。Objective-C或Swift语言为何不能实现一套类似的框架,瓶颈在于声明式语法这里?

一种更简单的UITableView使用方式

UITableView是iOS开发中非常常用的一个UI组件,然而传统的使用方式有点乏味,通常的写法是:

  • 将数据源放入一个数组中
  • 围绕这个数组定义UITableDataSource里的各个方法
  • 如果高度有变化则还需要实现UITableViewDelegate里的对应方法
  • 当你的数组里有多种类型的item时,上面所述的方法不可避免的包含一大堆ifelse

当一遍又一遍地重复写这些boilerplate代码时,日子就会变得很枯燥。有没有更加轻便点的写法呢?

objc.io早在issue 1里其实就提到了一个思路,于是我将它扩展了下,变成了一个支持多类型Cell的可重用的数据源(Github)。它封装了一个二维数组,实现了UITableViewDataSource和UITableViewDelegate的那些无聊琐屑的方法,只暴露了几个添加数据的接口,使用起来会轻松得多。废话不多说,看下面一段示例代码:

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
/*
 * 这是一段展示用户头像和照片流的代码,类似Instagram
 */

  NSArray *dataArray; //假设封装前的数据已都放入这个数组
  CCTableDataSource *ds = [[CCTableDataSource alloc] initWithTableView:tableView];

  CCTableComponent *seperator = [CCTableComponent componentWithCellConfigure:^(UITableViewCell *cell) {
    //特殊的分割样式
  } andHeight:8.5];

  for (User *user in dataArray) {//假设数组里都是User对象
    NSUInteger section = [tableDataSource addSection]; //每个用户一个Section
    CCTableComponent *userHeader = [CCTableComponent componentWithClass:[UserHeader class]//UserHeader继承自UITableViewHeaderFooterView 
                                                               data:user
                                                         identifier:@"user"];
    [tableDataSource setHeader:userHeader ofSection:section];//用户头像作为SectionHeader

    for (Photo *photo in user.photos) {
      CCTableComponent *photoCell = [CCTableComponent componentWithClass:[PhotoCell class]
                                                                data:photo
                                                          identifier:@"photo"
                                                       selectedBlock:^(NSIndexPath *indexPath,
                                                                       UITableViewCell<CCTableComponentDelegate> *cell,
                                                                       CCTableComponent *component) {
        //show large photo
      }];
      [self.tableDataSource addCell:photoCell toSection:section];
    }

    [self.tableDataSource addCell:seperator toSection:section];
  }

这段代码里使用了三种UI控件,分别是UserHeader、PhotoCell和SeperatorCell,如果采用传统的方式,那么代码会变得有点冗长…

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
/*
 * 假设数据都已放入self.dataArray
 */

-(void)initTable
{
  [self.tableView registerClass:[UserHeader class] forHeaderFooterViewReuseIdentifier:@"user"];
  [self.tableView registerClass:[PhotoCell class] forCellReuseIdentifier:@"photo"];
  [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"seperator"];
}

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
{
  return self.dataArray.count;
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
{
  return [(User *)self.dataArray[section] photos].count + 1; //photos + seperator
}

-(UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section
{
  UserHeader *header = (UserHeader *)[tableView dequeueReusableHeaderFooterViewWithIdentifier:@"user"];
  header.user = self.data[section];
  return header;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
  NSArray *photos = [(User *)self.dataArray[indexPath.section] photos];
  if (indexPath.row < photos.count) {
      PhotoCell *cell = (PhotoCell *)[tableView dequeueReusableCellWithIdentifier:@"photo"];
      cell.photo = photos[indexPath.row];
      return cell;
  } else {
      UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"seperator"];
      //configure for the first time
      return cell;
  }
}

-(CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath
{
  NSArray *photos = [(User *)self.dataArray[indexPath.section] photos];
  if (indexPath.row < photos.count) {
    //return photo cell height
  } else {
    return 8.5;
  }
}

-(void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
  NSArray *photos = [(User *)self.dataArray[indexPath.section] photos];
  if (indexPaht.row < photos.count) {
    Photo *photo = photos[indexPath.row];
    //show large photo
  }
}

注意为了便于阅读,第一段代码里我使用了折行,实际代码调用量的对比更加明显。而且随着业务的发展,我们很可能会增加Cell的种类。

  • 比如中间插条广告?
  • 推荐些达人?
  • 再展示点最新评论?
  • 当然还有必须的加载更多。。。

当这些都凑齐后,可想而知第二段代码会变得多么冗长,每个和section、cell相关的方法里都会塞满一堆ifelse判断。。。而第一段代码则依然保持优雅,你只需要按照数据源遍历一次,将各种类型的section或cell按顺序加进CCTableDataSource,剩下的工作就全交给它啦。

当然,想要实现这样的效果,光靠一个CCTableDataSource是不够的,你需要将自己定义的Cell或SectionHeaderFooter实现以下Protocol

1
2
3
4
5
6
@protocol CCTableComponentDelegate<NSObject>

-(void)configureWithData:(id)data; //你对UI的设置代码需要放在这里面
+(CGFloat)heightForData:(id)data; //给CCTableDataSource用来设置高度

@end

然后将其用一个CCTableComponent封装起来,CCTableDataSource里维护的就是一个二维的CCTableComponent数组,不管是Cell还是SectionHeaderFooter,都视为一个component。

1
2
3
4
5
6
7
8
@interface CCTableComponent : NSObject

@property (nonatomic, strong) Class<CCTableComponentDelegate> componentClass; //实际需要显示的UI Class
@property (nonatomic, strong) id data; //用于显示的数据
@property (nonatomic, strong) NSString *componentIdentifier; //重用的标识符
@property (nonatomic, strong) CCSelectCellBlock selectCellBlock; //选中后执行的block(不是必须)

@end

如果你需要使用TableViewDelegate里定义的其他方法,可以把定义的delegate传进来

1
-(id)initWithTableView:(UITableView *)tableview delegate:(id<UITableViewDelegate>)delegate;

CCTableDataSource会负责转发,但是要注意的是,我还没有实现所有的方法,如果需要你可以自行添加。

如果你的TableView结构简单,这么做可能没啥必要,但一旦你习惯这种写法后,当TableView内容开始复杂时会感觉愉悦不少,赶紧试试吧~

Github地址:https://github.com/perrywky/CCTableDataSource

一个移动端webkit浏览器的bug

上篇吐槽了IE,这篇说下移动端webkit内核浏览器的一个bug,当你滚动页面时,如果有元素的position属性从fixed变成relative,并修改了top后,这个元素会消失,等滚动结束后,又会出来。这是一个奇葩的bug,需要用奇葩的方式来解决,那就是给这个元素加上以下css样式

1
  -webkit-transform: translate3d(0, 0, 0);

是不是有点想掀桌?

强制IE重绘

这两天做chufaba.me的页面时,碰到一个很奇怪的bug,在IE浏览器下修改position为fixed时,页面会错乱,花了我两天时间都没搞定,感觉整个人都不好了。

后来在css3-mediaqueries.js里看到一个强制ie重绘的代码,灵机一动拿过来试试,果然有效,真想跪舔下作者!

1
2
3
4
  document.documentElement.style.display = 'block';
  setTimeout(function () {
    document.documentElement.style.display = '';
  }, 0);

问题虽然解决了,但我还是希望IE毁灭,如果我有超能力的话。

怎样正确设置remote_addr和x_forwarded_for

做网站时经常会用到remote_addrx_forwarded_for这两个头信息来获取客户端的IP,然而当有反向代理或者CDN的情况下,这两个值就不够准确了,需要调整一些配置。

什么是remote_addr

remote_addr代表客户端的IP,但它的值不是由客户端提供的,而是服务端根据客户端的ip指定的,当你的浏览器访问某个网站时,假设中间没有任何代理,那么网站的web服务器(Nginx,Apache等)就会把remote_addr设为你的机器IP,如果你用了某个代理,那么你的浏览器会先访问这个代理,然后再由这个代理转发到网站,这样web服务器就会把remote_addr设为这台代理机器的IP。

什么是x_forwarded_for

正如上面所述,当你使用了代理时,web服务器就不知道你的真实IP了,为了避免这个情况,代理服务器通常会增加一个叫做x_forwarded_for的头信息,把连接它的客户端IP(即你的上网机器IP)加到这个头信息里,这样就能保证网站的web服务器能获取到真实IP

使用HAProxy做反向代理

通常网站为了支撑更大的访问量,会增加很多web服务器,并在这些服务器前面增加一个反向代理(如HAProxy),它可以把负载均匀的分布到这些机器上。你的浏览器访问的首先是这台反向代理,它再把你的请求转发到后面的web服务器,这就使得web服务器会把remote_addr设为这台反向代理的IP,为了能让你的程序获取到真实的客户端IP,你需要给HAProxy增加以下配置

option forwardfor

它的作用就像上面说的,增加一个x_forwarded_for的头信息,把你上网机器的ip添加进去

使用Nginx的realip模块

当Nginx处在HAProxy后面时,就会把remote_addr设为HAProxy的IP,这个值其实是毫无意义的,你可以通过nginx的realip模块,让它使用x_forwarded_for里的值。使用这个模块需要重新编译Nginx,增加--with-http_realip_module参数

set_real_ip_from   10.1.10.0/24;
real_ip_header     X-Forwarded-For;

上面的配置就是把从10.1.10这一网段过来的请求全部使用X-Forwarded-For里的头信息作为remote_addr

将Nginx架在HAProxy前面做HTTPS代理

网站为了安全考虑通常会使用https连接来传输敏感信息,https使用了ssl加密,HAProxy没法直接解析,所以要在HAProxy前面先架台Nginx解密,再转发到HAProxy做负载均衡。这样在Web服务器前面就存在了两个代理,为了能让它获取到真实的客户端IP,需要做以下配置。

首先要在Nginx的代理规则里设定

proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;

这样会让Nginx的https代理增加x_forwarded_for头信息,保存客户的真实IP。

其次修改HAProxy的配置

option     forwardfor except 10.1.10.0/24

这个配置和之前设定的差不多,只是多了个内网的IP段,表示如果HAProxy收到的请求是由内网传过来的话(https代理机器),就不会设定x_forwarded_for的值,保证后面的web服务器拿到的就是前面https代理传过来的。

为什么PHP里的HTTP_X_FORWARDED_FOR和Nginx的不一样

当你的网站使用了CDN后,用户会先访问CDN,如果CDN没有缓存,则回源站(即你的反向代理)取数据。CDN在回源站时,会先添加x_forwarded_for头信息,保存用户的真实IP,而你的反向代理也会设定这个值,不过它不会覆盖,而是把CDN服务器的IP(即当前remote_addr)添加到x_forwarded_for的后面,这样x_forwarded_for里就会存在两个值。Nginx会使用这些值里的第一个,即客户的真实IP,而PHP则会使用第二个,即CDN的地址。为了能让PHP也使用第一个值,你需要添加以下fastcgi的配置。

fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;

它会把nginx使用的值(即第一个IP)传给PHP,这样PHP拿到的x_forwarded_for里其实就只有一个值了,也就不会用第二个CDN的IP了。

忽略x_forwarded_for

其实,当你使用了Nginx的realip模块后,就已经保证了remote_addr里设定的就是客户端的真实IP,再看下这个配置

set_real_ip_from   10.1.10.0/24;
real_ip_header     X-Forwarded-For;

它就是把x_forwarded_for设为remote_addr,而nginx里的x_forwarded_for取的就是其中第一个IP。

使用这些设置就能保证你的remote_addr里设定的一直都是客户端的真实IP,而x_forwarded_for则可以忽略了:)

整理Cacti模板

这两天装了些Cacti的模板,整理下留作备忘

Mysql

PHP-FPM

Nginx

  • http://forums.cacti.net/about26458.html
  • 使用楼主提供的模板,数据收集是一个perl的脚本,需要安装LWP::UserAgent模块,cpan LWP::UserAgent, 如果你之前没有用过cpan可能会提示你创建一些目录,一路回车好了

HAProxy

Memcached

Redis

  • https://github.com/perrywky/cacti-redis
  • 需要先装一个python的模块,easy_install redis
  • 将redis-stats.py放到cacti_home/scripts目录下
  • 使用以下命令测试 python redis-stats.py -a 'redis password' redis-host
  • 当你添加graph时,哪怕默认db0,也要填进去,否则不会有数据

扫描discuz里的垃圾帖

维护过discuz的人一定对各种垃圾广告深恶痛绝,虽然它自带了一个关键词屏蔽功能,但是这并不能有效地解决这个问题。

首先它是一种被动防御机制,没人刷你就不知道该屏蔽什么,而你又不可能时时刻刻守着,所以通常等你发现时已经晚了,对用户已产生了不好的影响,甚至有可能收到有关部门的警告…

其次垃圾商会用各种方式来绕过你的关键词审查,比如:

  • 用空格或全角空格等符号把关键词隔开
  • 用繁体字
  • 用unicode,如&#23567;&#22992;浏览器会把它显示为小姐

这是一个道高一尺魔高一丈的循环游戏,你永远处于被动,而且当他们用很多特殊字符来分隔关键词时,你基本上没有什么办法,正则也帮不了你。

过多的关键词还会对你的用户造成影响,比如“小姐”这个词,虽然很多黄色广告都包含,但是正常用户有时也会用到它,因为这个而影响用户发帖,是很不友好的体验。更不要说过多的关键词还会拖慢你论坛的发帖速度。

经过了一番研究后,我发现所有的垃圾贴都有一些共同的特征,为了吸引注意,它们通常都是在很短的时间里刷出来一大片,但是所有的标题都差不多,可能就是中间几个字换了下,例如这样

这样的标题应该都可以用某种计算字符串相似度的算法来做下评估,如果同时存在很多相似度很高的主题,那它们很有可能就是垃圾广告。抱着这个想法我做了下搜索,发现了一个计算编辑距离的算法,而且更棒的是PHP已经带了这个方法,不用自己来实现!有了这个算法,剩下的事就好办了。

因为PHP的这个方法不支持中文,所以得用这个版本。我对这个方法做了下小修改,让它直接返回相似度比例,参考

这个脚本还有一个好处就是能把纯灌水涨积分的帖子也屏蔽了,可谓一举两得。

有三个地方可以根据自己的情况做出调整

  • 一个是相似度,85%算是比较严格的要求,如果垃圾广告花样很多,可以适当降低这个标准
  • 对重复的数量也可以要求更低,至少两条重复就可以判定为垃圾广告,不过有可能误伤用户的重复发帖
  • 频率。如果你的站点比较繁忙,可以适当提到频率。目前这个脚本在我的服务器上跑没有任何负载,如果你的服务器也没什么压力的话,可以压缩间隔或者提高limit来提高处理速度

迁移到Octopress

折腾了一天终于把博客迁移到octopress了,虽然这是一个很geek的博客,但是我的迁移过程一点也不geek——jekyll提供的工具不够完善,导入过来的文章格式不对,尤其是和代码相关的,所以我把所有文章的格式都修复了下,还好我之前很懒文章很少,一篇篇调整也不算太麻烦,顺便还能温习下markdown语法,不然就真的需要研究下它的转化脚本了,花的时间可能更长。

想起之前在dbanotes上看到的文章,一味地追求技术可能适得其反,快速的把事情搞定才是王道。

顺便推荐下stdyun的octopress托管服务,完全免费,部署简单,速度也很快!

使用Node.js和Redis实现push服务

push服务是一项很有用处的技术,它能改善交互,提升用户体验。要实现这项服务通常有两种途径,轮询和长连接。轮询就是客户端每隔一段时间就问服务器拿新数据,实现起来很简单但是服务器压力很大,而且大部分请求因为没有新数据都显得很浪费。长连接则是服务器将一个请求挂起,不输出任何内容,直到有新数据产生后才会完成这个请求,浏览器收到响应后则马上再发一个又让服务器挂住,如此反复。这么做的好处是能节省很多无用的请求,但是它不能使用传统的服务端软件,比如apache和php-fpm,客户端多了的话很容易把所有进程占光,这样服务器就没法响应新的请求了。

Node.js让这一切变得简单,它是基于事件和非阻塞I/O的服务器技术,能使用极少的资源响应大量并发的请求,非常适合长连接的要求。但是这样做还存在两个问题。首先你的服务端通常是用另外一套语言和框架做的,有成熟的代码和业务逻辑,为了实现这个push功能,难道又要用javascript来写一套吗?维护起来不嫌麻烦?其次,服务端把请求挂起后,也是不断地重复调用其它服务来获取新数据,这不过是把轮询的代码换个位置而已,本质上没区别,对服务器一样有压力。

有没有什么简单的办法来实现高效的push呢?

答案是有,而且很简单,所需代码不超过20行!

首先我们借助Redis的Pub/Sub功能来实现真正的push,其次用JSON来作为客户端和服务端沟通的数据格式。

当Node.js收到请求后,我们将请求挂起,同时实例化一个Redis客户端,并根据请求里的参数来收听一个特定的频道,原有的服务端代码(比如PHP)处理完业务逻辑后,将新数据用JSON封装下发布到这个频道,Node.js收到消息后将其作为响应传给客户端,这样就完成了一次push。代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var http = require('http');
var url = require('url');
var redis = require('redis');
http.createServer(function (req, res) {
    var query = url.parse(req.url, true).query;
    if(typeof query.channel == 'undefined'){
        res.writeHead(200, {'Content-Type': 'text/plain'});
        res.end('Invalid Request\n');
    }else{
        var client = redis.createClient(6379, '127.0.0.1');
        client.subscribe(query.channel);
        client.on('message', function(channel, message){
            res.writeHead(200, {'Content-Type': 'application/json'});
            res.end(message);
            client.unsubscribe();
            client.end();
        });
    }
}).listen(1337, '127.0.0.1');

这个方法简单易用,你只需要定好一个频道和数据关系的协议,然后对现有代码做些简单修改,就能实现一个高效的push服务了!