再谈React同构应用:服务端下复用Redux Effects的实践

同构 (universal/isomorphic) React应用旨在服务端(或者是网关层、中途岛层)和客户端(浏览器端)尽可能地复用UI组件的代码,以提高项目的可维护性。当同构应用引入以 Redux 为首的数据流管理、以 react-router 为主的SPA前端路由后,同构应用将变得复杂:我们需要在服务端和客户端之间同步状态(store)和路由信息,并且尽可能地复用这些数据逻辑(如reducers)和路由配置。关于如何搭建这样的一个项目框架,你可以阅读 Server Side Rendering with React and Redux

本文假设你已经熟悉如何搭建一个 React + Redux + react-router 的同构应用,我们来讨论Redux副作用(side effects,后面简称effects)在服务端复用的逐步尝试和实践。

目前的典型场景

目前大多数React同构脚手架均不在服务端复用effects,而是通过直接调用Service模块的方式来加载数据,这使得我们可以直接获知异步任务何时完成,并在回调函数中直接执行我们的渲染逻辑。在渲染逻辑中,因为页面初始数据已经取得,从创建store到调用store.getState()来初始化渲染模板都是同步的,没有任何坑点,它看起来是这样的:

1
2
3
4
5
6
APIService.getTodos().then((initialData) => {
const store = configureStore(makeInitialState(initialData));
const html = ReactDOMServer.renderToString( /* ... */ );
const state = store.getState()
renderFullPage(html, state);
});

例:universal-react-starter-kit

以国内比较流行的脚手架 bodyno/universal-react-starter-kit 为例,其渲染部分的关键代码是这样的:
server/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const initialState = await router(ctx)
const store = createStore(initialState, memoryHistory)
/* ... */
match({history, routes, location: ctx.req.url}, async (err, redirect, props) => {
/* ... */
let layout = {
/* ... */
{type: 'text/javascript', innerHTML: `___INITIAL_STATE__ = ${JSON.stringify(store.getState())}`},
/* ... */
]}
/* ... */
content = renderToString(
<AppContainer layout={layout} />
);
});

其中 await router(ctx)router部分代码如下:
server/router.js

1
2
3
4
5
6
7
8
export default async function (ctx) {
return new Promise((resolve, reject) => {
/* ... */
axios.get('https://api.github.com/zen').then(({data}) => {
resolve({zen: { text: [{text: data}]} })
})
})
}

await router(ctx)在此处就是一次Service API调用。先不论这个router是否名不符实(可能因为是脚手架的原因。router.js应该是给开发者填入代码来实现对应不同路由调用不同的Service),这个脚手架的渲染逻辑跟上文的示例大同小异——直接调用Service模块异步取得初始数据,在回调(await)中通过全同步的方式用初始数据产生store并getState(),然后调用renderToString()渲染。

在服务端通过“直捅Service”的方式来获取页面初始数据,是最直接、最简单的方法。当然我们在客户端绝对不会这么做,在客户端我们会设计好同步的actions和reducers,并通过触发effects来实现异步数据获取。为了使我们的服务端代码更优雅、维护性更强、代码复用度更高,我们希望在服务端能够复用这些actions、reducers和effects。

使用redux-thunk的场景

在服务端执行一个effect是很简单的,我们只需要调用在服务端和客户端间共享的configureStore()函数来创建一个空的store(这时你将拥有effects所必须的middleware),然后调用store.dispatch()来触发一个绑定了effects的action即可。难点是:程序如何得知一个异步effects已经执行完成?这样我们才能在effects完成后调用store.getState()来取得带初始数据的state。
如果你的项目所使用的effects是 redux-thunk,你可以很容易地在服务端复用它们:你只需要在thunk函数中返回一个promise即可——而这是官方建议的标准写法。这样,store.dispatch()可以直接返回这个promise。
你的async thunk action creator看起来是这样的:

1
2
3
4
5
6
7
8
9
10
function fetchTodos() {
return function(dispatch) {
dispatch({ type: 'todos/get' });
return APIService.getTodos()
.then(payload => dispatch({
type: 'todos/get/success',
payload,
}));
}
}

APIService看起来是这样的:

1
2
3
const APIService = {
getTodos: () => fetch('/api/todos').then(response => response.json()),
}

这样,在服务端的渲染逻辑,你可以这样写:

1
2
3
4
5
6
7
const store = configureStore({});
store.dispatch({ type: 'todos/get' })
.then(() => {
const html = ReactDOMServer.renderToString( /* ... */ );
const state = store.getState()
renderFullPage(html, state);
});

另外,还有 redux-promise 的effects解决方案。在服务端复用方面,redux-promise和redux-thunk极为相似,因为使用redux-promise同样可以通过store.dispatch()获得异步任务的promise。
唯一的不同之处是,当使用redux-promise时,async action creator看起来是这样的:

1
2
3
4
5
6
function getTodos() {
return {
type: 'todos/get',
payload: APIService.getTodos(), //action.payload是一个promise
}
}

例:react-redux-universal-hot-example

让我们来看看GitHub上stars最多的Universal React脚手架 erikras/react-redux-universal-hot-example 是怎么解决的。
这个脚手架使用了 redux-async-connect middleware,这使得我们可以绑定一个promise给每一个container,并在服务端使用它提供的loadOnServer()方法获得待渲染的container的异步任务及其promise。
src/containers/App/App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@asyncConnect([{
promise: ({store: {dispatch, getState}}) => {
const promises = [];
if (!isInfoLoaded(getState())) {
promises.push(dispatch(loadInfo()));
}
if (!isAuthLoaded(getState())) {
promises.push(dispatch(loadAuth()));
}
return Promise.all(promises);
}
}])
@connect(
state => ({user: state.auth.user}),
{logout, pushState: push})
export default class App extends Component {
/* ... */
}

src/server.js

1
2
3
4
5
6
7
8
9
10
11
loadOnServer({...renderProps, store, helpers: {client}}).then(() => {
const component = (
<Provider store={store} key="provider">
<ReduxAsyncConnect {...renderProps} />
</Provider>
);
res.status(200);
global.navigator = {userAgent: req.headers['user-agent']};
res.send('<!doctype html>\n' +
ReactDOM.renderToString(<Html assets={webpackIsomorphicTools.assets()} component={component} store={store}/>));
});

从上面的代码中,我们看到:

  • 作者使用redux-async-connect将container和一个promise绑定,这个promise执行多个dispatch()调用,当它们返回的promise都resolve时才resolve自身。
  • 服务端通过调用已经绑定的loadOnServer()方法得到上述的这个promise,从而可以直接在.then()中填写该promise执行完成后的同步渲染逻辑。
  • 之所以能够这么做,还是依赖于redux-thunk的store.dispatch()调用能够返回异步任务对应的promise。

使用redux-saga的场景

然而,对于业务逻辑逐渐复杂的Web APP,redux-thunk或许不能满足复杂的数据流场景。现在国内最流行的Effects方案莫过于 redux-saga 了。

redux-saga使得异步effects完全脱离于原生Redux数据流,没有Async Action creator(你甚至不需要多余的Action Creator)。Saga effects更像是运行于另一个线程的一组任务(除了Web Worker外目前客户端JavaScript还没有真正意义上的多线程),这些任务可以监听特定的action,并在不直接影响Redux数据流的前提下执行异步操作。

因为redux-saga的这些优点,使得它可以实现更复杂的异步数据流,保留更纯净的原生Redux流,这非常优雅。而正因如此,它不会对store.dispatch()的返回值做任何更改——这意味着,在服务端我们不能指望仅仅通过store.dispatch()就能获知我们的初始数据何时到达。

这时我想到了参考已有的、使用redux-saga的同构脚手架。

dva提供的同构脚手架

dva ——蚂蚁金服推出的一个轻量级框架,基于redux、redux-saga和react-router,让你能够使用类似 elm-lang 的声明性风格来组织你的代码。

dva官方提供的同构脚手架是 sorrycc/dva-boilerplate-isomorphic 。让我们来看看它是怎么解决saga在服务端下的渲染的。
server/ssrMiddleware.js

1
2
3
4
5
6
7
8
9
10
11
12
import { fetchList } from '../common/services/user';
// ...
fetchList()
.then(({ err, data }) => {
const initialState = { user: data };
const app = createApp({
history: createMemoryHistory(),
initialState,
}, /* isServer */true);
const html = renderToString(app.start()({ renderProps }));
res.end(renderFullPage(html, initialState));
});

common/services/user.js

1
2
3
4
import request from '../utils/request';
export function fetchList() {
return request('/api/users');
}

看到这里,相信大家都明白了。dva在这里的服务端逻辑是“直捅Service”的。dva的官方脚手架并没有解决我们的问题。

官方建议的runSaga()

事实上,对于redux-saga的服务端渲染问题,早就有关于这个的讨论,参考 issue #13 。而redux-saga已添加了 runSaga() 方法来实现在服务端复用saga effects。

runSaga()接收一个saga对象和必须的store输入输出方法(subscribe()dispatch()等),允许在store上下文之外执行一个saga任务,并返回一个Task实例对象。返回的Task对象中的done属性是一个promise对象的引用,该promise在传入的saga任务执行完成后resolve。

假设我们有这样的一个saga effect:

1
2
3
4
function* getTodos() {
const payload = yield call(APIService.getTodos);
yield put({ type: 'todos/get/success', payload });
}

由于我们可以获得store上下文和sagaMiddleware,在这里我们可以直接使用sagaMiddleware.run()来代替runSaga()sagaMiddleware.run()同样返回对应这个saga任务的Task对象。

1
2
3
4
5
6
7
8
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, initialState, compose(applyMiddleware(sagaMiddleware)));
const task = sagaMiddleware.run(getTodos);
task.done.then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
});

至此,我们貌似已经能够比较完美地在服务端复用saga effects了。

更为复杂的saga

如果我们的saga比较复杂呢?比如像这样的:

1
2
3
4
5
6
7
8
9
10
function* loginFlow() {
while (true) {
yield take('user/login');
const payload = yield call(APIService.login);
yield put({ type: 'user/login/success', payload });
yield take('user/logout');
yield call(APIService.logout);
yield put({ type: 'user/logout/success' });
}
}

这个task是一个典型的infinite saga flow,也是redux-saga相对于其他effects所独有的特性:我们可以随心所欲地定义“看起来是阻塞”的数据流任务,来解决复杂的业务场景,而无需担心阻塞任务会对UI线程造成影响。
这样的死循环saga数据流在客户端用起来是很高效优雅的,但到了服务端,这将造成严重的问题——这个saga永远不会结束,因此task.done.then()永远不会被回调,我们无法知道我们所需的数据什么时候加载完成。

对于更为普遍的情况,我们是这样定义saga任务的,比如使用蚂蚁的 ant-design/antd-init 脚手架:
src/sagas/todos.js 中定义了todos的saga:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* getTodos() {
const { jsonResult } = yield call(getAll);
if (jsonResult.data) {
yield put({
type: 'todos/get/success',
payload: jsonResult.data,
});
}
}
function* watchTodosGet() {
yield takeLatest('todos/get', getTodos)
}
export default function* () {
yield fork(watchTodosGet);
yield put({ type: 'todos/get', });
}

src/sagas/index.js 负责组合全部model的saga(通过fork()调用),并导出一个rootSaga

1
2
3
4
5
6
7
const context = require.context('./', false, /\.js$/);
const keys = context.keys().filter(item => item !== './index.js' && item !== './SagaManager.js');
export default function* root() {
for (let i = 0; i < keys.length; i ++) {
yield fork(context(keys[i]));
}
}

请注意这里的takeLatest()调用。takeLatest()是redux-saga的一个helper方法,而不是effect方法。参考 redux-saga API文档中的takeLatest,我们可以看到takeLatest()是这样实现的:

1
2
3
4
5
6
7
8
9
10
11
12
function* takeLatest(pattern, saga, ...args) {
const task = yield fork(function* () {
let lastTask
while (true) {
const action = yield take(pattern)
if (lastTask)
yield cancel(lastTask)
lastTask = yield fork(saga, ...args.concat(action))
}
})
return task
}

所以,当我们在saga中进行了一次yield takeLatest()之后,实际上是fork()出了一个带死循环数据流的另一个saga,而这个死循环的saga当然是永远不会结束的,除非它被我们人为cancel()
还有一个问题是关于redux-saga的fork模型:被fork()出来的子saga与其父saga有怎样的生命周期关联?redux-saga的官方文档 给了我们最好的回答:

In fact, attached forks shares the same semantics with the parallel Effect:

  • We’re executing tasks in parallel
  • The parent will terminate after all launched tasks terminate

意思是,父saga只有当其所有fork()出来的子saga都结束后才会结束(这和操作系统的fork模型是类似的)。这意味着,因为其子saga中带有死循环流,我们的rootSaga也是永远不会自发结束的。这样的话,我们就 不能 这么写:

1
2
3
4
5
const task = sagaMiddleware.run(rootSaga);
store.dispatch({ type: 'todos/get' });
task.done.then(() => {
// 这里的代码不会被执行
});

我们只能够直接run()不带死循环流的saga来获得初始数据,像这样:

1
2
3
4
5
6
const task = sagaMiddleware.run(getTodos);
task.done.then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
});

这跟我们刚才提到的官方建议的方法没有任何区别。在服务端我们需要规避那些包含死循环流的saga,如watchTodosGet

这将导致客户端和服务端出现大量的 异构 :在客户端,我们直接执行rootSaga,通过dispatch()特定的action来获取数据并同步到state;而在服务端,我们需要找到并执行可以获取到数据并且不带死循环的saga,如getTodos

使用redux-wait-for-action来搭救

为了将 同构 进行到底,博主写了一个Redux middleware来解决这个问题: redux-wait-for-action 。这个代码不到80行的middleware主要实现了:在dispatch一个action时,同时指定另外一个我们期望收到的action,store.dispatch()返回一个promise,当这个我们期望的action到达时,该promise将resolve。
这样,我们可以在服务端复用rootSaga而不需要关心这个rootSaga何时结束。同时,在服务端创建的store,其生命周期将在http响应完成后结束,我们甚至不需要手动cancel()这个看似不会自发结束的rootSaga——交给GC来杀死它们就行了。
我们不妨写一个在客户端和服务端通用的configureStore()方法来创建我们的store,并且执行我们的rootSaga

1
2
3
4
5
6
7
8
9
10
const configureStore = (initialState) => {
const sagaMiddleware = createSagaMiddleware();
let enhancer = compose(
applyMiddleware(sagaMiddleware),
applyMiddleware(createReduxWaitForMiddleware()),
);
const store = createStore(rootReducer, initialState, enhancer);
sagaMiddleware.run(rootSaga);
return store;
};

在服务端渲染逻辑中,我们只需要直接dispatch()这个action即可——这和在客户端获取数据的方式完全相同:

1
2
3
4
5
6
7
8
9
const store = configureStore({});
store.dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
}).then(() => {
const html = ReactDOMServer.renderToString(/* ... */);
const state = store.getState();
renderFullPage(html, state);
})

在上面的示例代码中,我们在dispatch()一个action时,在这个action中增加了一个属性WAIT_FOR_ACTIONWAIT_FOR_ACTION是一个从redux-wait-for-action导入的ES6 Symbol对象,因此你不需担心这会污染你的action),该属性指定了另一个我们所期望的action todos/get/success。这个store.dispatch()调用返回一个promise,当action todos/get/success到达时,这个promise将resolve,因此我们可以在它的.then()中填写我们的渲染逻辑——因为这时我们所需的数据已经准备好。

由于redux-wait-for-action是基于等待action的,它将适用于近乎全部的effects方案(当然,对于redux-thunk和redux-promise则没有这个必要),当以后有更为流行的effects方案时,我们仍然可以使用这个middleware。
关于更具体的使用方法,大家可以参考 README for redux-wait-for-action

更优雅地组织同构应用

以上示例都是基于在服务端进行路由判断并决策执行哪个effects的,当我们的数据模型变得多时,服务端代码将变得复杂。比如:该dispatch todos/get还是profile/get?我们需要对req.url进行一一判断。

借助react-router的match()方法,我们能够得到对应路由下的container组件,如果我们能在每个路由下的container组件中定义一个fetchData()方法来dispatch合适的action,我们就可以大大简化服务端的代码,并且可以同时在服务端和客户端都使用它来加载页面数据。

在每个路由节点对应的container的代码中,添加一个fetchData() 静态 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class TodosContainer extends Component {
static fetchData(dispatch) {
return dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
});
}
componentDidMount() {
// 这个钩子方法仅会在客户端被调用
TodosContainer.fetchData(this.props.dispatch);
}
// ...
}

在服务端渲染代码中,我们定义一个getReduxPromise()函数,这个函数抽出当前路由下对应的container组件,并调用其中的fetchData()方法,从而得到一个promise。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
match({history, routes, location: req.url}, (error, redirectLocation, renderProps) => {
/* 前面这里需要处理redirectLocation、error和renderProps为null的情况 */
/* ... */
const getReduxPromise = () => {
const component = renderProps.components[renderProps.components.length - 1].WrappedComponent;
const promise = component.fetchData ?
component.fetchData(store.dispatch) :
Promise.resolve();
return promise;
};
getReduxPromise().then(() => {
const initStateString = JSON.stringify(store.getState());
const html = ReactDOMServer.renderToString(
<Provider store={store}>
{ <RouterContext {...renderProps}/> }
</Provider>
);
res.status(200).send(renderFullPage(html, initStateString));
});
});

遇到需要传递cookie或参数的情况,我们可以稍微修改一下fetchData()

1
2
3
4
5
6
7
static fetchData(dispatch, query, cookies) {
return dispatch({
type: 'todos/get',
[ WAIT_FOR_ACTION ]: 'todos/get/success',
query, cookies,
})
}

在服务端调用fetchData()时:

1
component.fetchData(store.dispatch, req.query, req.cookies);

由于客户端一般不需要在XHR中显式加cookie,因此我们在客户端调用fetchData()时忽略cookies参数即可,并在APIService模块中做适当的判断。

另外,为了节省篇幅和便于理解,以上各处示例代码中均没有异常处理部分(或被去除)。在实际项目中,请务必在effects中添加try-catch逻辑,并在promise的处理部分添加.catch()异常处理方法。

博主的脚手架

为了在实践中更好地理解以上所提到的最优化方案,博主写了这个脚手架,同时便于大家快速搭建同构React应用:
react-redux-universal-minimal

tinc VPN+策略路由:Linux下更好的科学上网方式

tinc是一个基于网状网络的VPN软件,使用tinc架设VPN,对于远程办公、文件传输等需求都是十分方便的。
而作为VPN,我们同样可以通过redirect gateway的方式来实现科学上网。本文将介绍通过使用tinc VPN和配置策略路由的方式,实现Linux平台下的科学上网(国内请求不走代理,国外请求走代理)。

本文教程以Arch Linux和OpenWRT为例,配置思路同样适用于其他Linux发行版。

相比于使用shadowsocks进行科学上网,tinc+策略路由有以下优势:

  • VPN是工作在IP层(网络层)的,因此可以实现对IP层packet进行代理,比如基于ICMP的ping和traceroute命令;而shadowsocks只能代理传输层的TCP和UDP请求。
  • tincd进程和服务器间的通信是基于UDP的(对于屏蔽UDP的ISP,tinc会自动failover到TCP),而该socket数量是一直固定的,对于本地发出的需要代理的连接(无论是IP层还是传输层),可实现多路复用,大大提高性能。而shadowsocks对于每一个本地TCP连接,均需要向服务器建立一次新的TCP连接,速度十分有限。
  • shadowsocks服务器有可能因为同时打开太多的TCP连接而拒绝请求,需要几分钟才能恢复。这种拒绝有可能是在ASP的防火墙上发生的,修改vps的内核参数如file-max等也不能解决。博主的VPS服务器上的shadowsocks服务每隔几天就会遇到一次这样的情况。而tinc因为基于多路复用,则没有这个问题。

条件

要架设可用于科学上网的tinc服务,你需要拥有:

  • 一台境外的tinc服务器。你可能需要自行搭建一台tinc服务器,要求:tun/tap设备可用,操作系统为Linux发行版。
  • 一台本地的Linux机器,可以是你的PC、软路由,或者是一台OpenWRT路由器,同样要求tun/tap可用。
  • 确认服务器和本地机器上已安装iptables, iproute2, ipset。
  • 本文非傻瓜教程,无法涵盖全部Linux发行版的操作,你需要熟悉自己所使用的发行版,如service,systemd等基本操作,遇到问题要知道如何排查。

tinc安装及配置

你可以参考Arch Linux wiki来安装和配置tinc。这里博主简要介绍快速部署方法。

  1. 在服务器和本地机器上安装tinc。

    Arch Linux:

    1
    2
    # pacman -Syu
    # pacman -S tinc

    OpenWRT:

    1
    2
    # opkg update
    # opkg install tinc
  2. 在服务器和本地机器上创建tinc配置文件夹。请替换myvpn为你喜欢的vpn名称。

    1
    2
    # mkdir -p /etc/tinc/myvpn
    # mkdir /etc/tinc/myvpn/hosts
  3. 服务端配置文件(在服务器上操作):
    你可以将alpha替换为自己喜欢的服务器标识名,下同。
    /etc/tinc/myvpn/tinc.conf

    1
    2
    Name = alpha
    Device = /dev/net/tun

    /etc/tinc/myvpn/tinc-up

    1
    2
    3
    4
    #!/bin/sh
    ip link set $INTERFACE up
    ip addr add 192.168.100.1/32 dev $INTERFACE
    ip route add 192.168.100.0/24 dev $INTERFACE

    /etc/tinc/myvpn/tinc-down

    1
    2
    3
    4
    #!/bin/sh
    ip route del 192.168.100.0/24 dev $INTERFACE
    ip addr del 192.168.100.1/32 dev $INTERFACE
    ip link set $INTERFACE down

    添加脚本执行权限:

    1
    2
    # chmod +x /etc/tinc/myvpn/tinc-up
    # chmod +x /etc/tinc/myvpn/tinc-down
  4. 本地机配置文件(在本地机器上操作):
    你可以将beta替换为自己喜欢的客户端标识名,下同。
    /etc/tinc/myvpn/tinc.conf

    1
    2
    3
    Name = beta
    Device = /dev/net/tun
    ConnectTo = alpha

    /etc/tinc/myvpn/tinc-up

    1
    2
    3
    4
    #!/bin/sh
    ip link set $INTERFACE up
    ip addr add 192.168.100.100/32 dev $INTERFACE
    ip route add 192.168.100.0/24 dev $INTERFACE

    /etc/tinc/myvpn/tinc-down

    1
    2
    3
    4
    #!/bin/sh
    ip route del 192.168.100.0/24 dev $INTERFACE
    ip addr del 192.168.100.100/32 dev $INTERFACE
    ip link set $INTERFACE down

    添加脚本执行权限:

    1
    2
    # chmod +x /etc/tinc/myvpn/tinc-up
    # chmod +x /etc/tinc/myvpn/tinc-down
  5. 在服务器上建立host配置文件并生成密钥:
    /etc/tinc/myvpn/hosts/alpha 请将10.0.0.1替换为服务器的公网IP。

    1
    2
    3
    Address = 10.0.0.1
    Port = 655
    Subnet = 0.0.0.0/0

    生成密钥:

    1
    # tincd -n myvpn -K
  6. 在本地机器上建立host配置文件并生成密钥:
    /etc/tinc/myvpn/hosts/beta

    1
    2
    Port = 655
    Subnet = 192.168.100.100/32

    生成密钥:

    1
    # tincd -n myvpn -K
  7. 在服务器和本地机上交换host配置文件:
    复制服务器上的/etc/tinc/myvpn/hosts/alpha到本地机器的/etc/tinc/myvpn/hosts/alpha
    复制本地机器上的/etc/tinc/myvpn/hosts/beta到服务器上的/etc/tinc/myvpn/hosts/beta

  8. 在服务器和本地机上启动tinc服务:(先启动服务器上的)

    Arch Linux和其他基于systemd管理的linux发行版:

    1
    # systemctl start [email protected]

    OpenWRT和其他发行版:

    1
    # tincd -n myvpn
  9. 测试VPN是否正常:

    1
    2
    3
    $ ifconfig #是否找到了myvpn接口?分配的IPv4地址是否正确?
    $ ping 192.168.100.1 #ping服务器
    $ ping 192.168.100.100 #ping客户端

配置服务器路由规则

以下操作在tinc服务器上进行:

  1. 开启ip_forward:

    Arch Linux:

    1
    2
    # echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-ipforword.conf
    # sysctl --system

    其他Linux发行版:

    1
    # vim /etc/sysctl.conf

    net.ipv4.ip_forward=0修改为net.ipv4.ip_forward=1。如果文件为空或没有这行,则添加一行net.ipv4.ip_forward=1即可。
    随后运行这条命令使配置生效:

    1
    # sysctl -p
  2. 开启masquerade:
    首先通过ifconfig命令(Arch Linux上使用ip addr),找出外网IP对应的接口名称。这里假设外网接口是eth0
    运行命令:

    1
    # iptables -t nat -A POSTROUTING -o eth0 -s 192.168.100.0/24 -j MASQUERADE

    你可以保存当前的iptables配置:

    1
    # iptables-save > /etc/iptables/iptables.rules

    在Arch Linux上,需要开启iptables.service来实现重启后保留配置:

    1
    # systemctl enable iptables.service

    在CentOS6上,需要开启iptables服务来实现重启后保留配置:

    1
    # chkconfig iptables on

配置策略路由

确认VPN架设成功,客户端和服务端能够互相ping通后,我们可以进行策略路由的配置了。
为了方便各位配置,博主已经写好了配置脚本。

以下操作在本地机器上进行:

  1. 下载国内IP段文件,保存至/etc/chn_route.list

    1
    # wget https://raw.githubusercontent.com/Chion82/soft-router/master/tinc_proxy/chn_route.list -O /etc/chn_route.list
  2. 下载策略路由初始化和停止脚本,保存至/usr/bin/目录:

    1
    2
    3
    4
    5
    # cd /usr/bin
    # wget https://raw.githubusercontent.com/Chion82/soft-router/master/tinc_proxy/init_tinc_proxy -O init_tinc_proxy
    # wget https://raw.githubusercontent.com/Chion82/soft-router/master/tinc_proxy/stop_tinc_proxy -O stop_tinc_proxy
    # chmod +x init_tinc_proxy
    # chmod +x stop_tinc_proxy
  3. 修改启动脚本:

    1
    # vim /usr/bin/init_tinc_proxy

    将第2行VPN_SERVER=XX.XX.XX.XXXX.XX.XX.XX修改为tinc服务器的外网IP地址。
    将第5行VPN_INTERFACE=chionvpnchionvpn修改为myvpn,或者是刚才你自定义的vpn名称。
    如果你的Linux发行版不是Arch Linux,请删除最后这两行:

    1
    2
    #Set rp_filter
    echo 2 > /proc/sys/net/ipv4/conf/$VPN_INTERFACE/rp_filter
  4. 修改停止脚本:

    1
    # vim /usr/bin/stop_tinc_proxy

    将第2行VPN_INTERFACE=chionvpnchionvpn修改为myvpn,或者是刚才你自定义的vpn名称。
    如果你的Linux发行版不是Arch Linux,请删除第7和第8行:

    1
    2
    #Restore rp_filter
    echo 1 > /proc/sys/net/ipv4/conf/$VPN_INTERFACE/rp_filter
  5. 修改tinc-up文件:

    1
    # vim /etc/tinc/myvpn/tinc-up

    在最后一行添加:

    1
    /usr/bin/init_tinc_proxy &
  6. 修改tinc-down文件:

    1
    # vim /etc/tinc/myvpn/tinc-down

    在第一行#!/bin/sh
    下方插入一行:

    1
    /usr/bin/stop_tinc_proxy
  7. 重启tinc来测试配置是否正确:

    Arch Linux:

    1
    # systemctl restart [email protected]

    其他Linux发行版:

    1
    2
    # tincd -n myvpn -k
    # tincd -n myvpn

    进行测试:

    1
    $ ping 172.217.27.132

    如果能够ping通,说明以上配置正确。

配置ChinaDNS和dnsmasq

至此,我们的VPN和策略路由已经配置完成。为了避免国内DNS污染,我们需要使用ChinaDNS。ChinaDNS的配置方法与之前的OpenWRT科学上网类似,唯一不同处是,这里的国外上游DNS服务器我们可以直接填写8.8.8.8,而不需要shadowsocks的ss-tunnel隧道。

以下操作在本地机器上进行:

  1. 安装并配置ChinaDNS

    Arch Linux:

    1
    2
    3
    # yaourt -S chinadns
    # cp /etc/chn_route.list /etc/chnroute.txt
    # system start chinadns.service

    OpenWRT:

    参照OpenWRT科学上网 #安装ChinaDNS来安装ChinaDNS,然后执行:

    1
    # cp /etc/chn_route.list /etc/chinadns_chnroute.txt

    进入OpenWRT管理网页,进入services->ChinaDNS,勾选Enable,中国路由表(CHNRoute File)填/etc/chinadns_chnroute.txt,设置Upstream Servers为:114.114.114.114,8.8.8.8

  2. 配置dnsmasq

    Arch Linux:

    1
    # pacman -S dnsmasq

    修改/etc/dnsmasq.conf,清空文件并填入以下内容:

    1
    2
    3
    4
    listen-address=127.0.0.1 #如果机器(如软路由)绑定了静态IP,请在这里加上静态IP,以逗号分割
    no-resolv
    server=127.0.0.1#5353

    启动dnsmasq:

    1
    # systemctl start dnsmasq.service

    修改本机的DNS配置,使其指向127.0.0.1
    如果你的网络配置文件管理器是netctl,在对应的配置文件(位于/etc/netctl/下)中设置:

    1
    DNS=('127.0.0.1')

    OpenWRT:

    进入网络(Network)->DHCP and DNS。
    将DNS转发(DNS forwardings)设置为127.0.0.1#5353
    还要记得勾选“忽略解析文件”(ignore resolve file)。

  3. 测试
    现在应该能够ping通谷歌域名了:

    1
    2
    $ dig www.google.com
    $ ping www.google.com

配置自启动

如果需要在机器启动时自动开启科学上网,可按照以下步骤进行:

  1. 自启动tinc服务,在服务器和本地机器上操作:

    Arch Linux:

    1
    # systemctl enable [email protected]

    其他Linux发行版:

    /etc/rc.local 脚本文件最后添加一行:

    1
    tincd -n myvpn

    当然,更好的方法是编写一个init服务脚本(位于/etc/init.d/)。

  2. 自启动ChinaDNS服务,在本地机上操作:

    Arch Linux:

    1
    2
    # systemctl enable chinadns.service
    # systemctl enable dnsmasq.service

    OpenWRT:

    不需要特别设置,ChinaDNS和dnsmasq服务在安装后默认是自启动的。

至此,全部配置已经完成了,你现在可以上youtube看大新闻了。

策略路由原理及常见问题

  1. init_tinc_proxy这个脚本都做了些什么?

    • 首先,读取/etc/chn_route.list文件,这个文件的内容是国内IPv4的CIDR地址段。创建一个ipset集合chn_route,将这些国内地址段写入该集合。
    • 添加一个路由表,id为200,该路由表接受全部IP段(0.0.0.0/0,或default),经由接口myvpn,网关(下一跳)是VPN服务器192.168.100.1。即执行:

      1
      # ip route add default via 192.168.100.1 dev myvpn tabel 200
    • 添加一个路由规则,将MARK为200的IP报使用id为200的路由表进行路由。即执行:

      1
      # ip rule add fwmark 200 table 200
    • 在iptables的mangle表增加一个自定义链tinc_proxy,并在该链中添加如下规则:
      目的地址在BYPASS指定的例外IP段中的packet,采取RETURN处理;
      目的地址在chn_route集合中的packet,采取RETURN处理;
      恢复CONNMARK的值到MARK(CONNMARK:用于跟踪一个连接的标记值);
      对MARK的值为0的packet,设置其MARK为200
      将MARK的值保存到CONNMARK;

    • 在mangle表的PREROUTINGOUTPUT链中插入自定义链tinc_proxy
    • 在nat表的POSTROUTING链中,对出口接口为myvpn的packet,采取MASQUERADE处理。
  2. 为什么在Arch Linux下,需要将内核参数/proc/sys/net/ipv4/conf/$VPN_INTERFACE/rp_filter设为2
    rp_filterReverse Path Filtering (反向路径过滤),其原理是:
    内核对目的地址为本机的每个IP报文,先检查其来源地址,然后根据本机路由表,查找到该来源地址的路由(即反向路径查找),若查找到的路由对应的接口与该报文实际到达所经过的接口不相符,则抛弃该包。显然,这对基于fwmark的策略路由是不适用的,因此需要关闭反向路径过滤功能。
    Arch Linux下默认使用严格的反向路径过滤策略,需要将该值设置为2
    而其他发行版,只需要将该值保持为默认的0即可。

  3. tinc VPN架设成功后,服务器和客户端能够互相ping通,但是无法经由服务器科学上网?
    请逐步排查,特别注意服务器的tinc host配置文件中,Subnet是否正确设置为0.0.0.0
    参考Example: redirecting the default gateway to a host on the VPN将全部流量都经过VPN,看看能否正常访问外网。

ChionLab 2016年底更新记录

早上好。本站从建立至今已将近一年,博主最近对小站进行了若干修改和调整,具体包括:

  1. 新的样式主题 Uzume ,并保留原主题 Miria ,在站点顶部banner可切换主题。
    • 新主题 Uzume 角色是 天王星うずめ (天王星涡芽),出自游戏 新次元ゲイム ネプテューヌVII (新次元游戏 海王星VII,PSN港区译作 新次元遊戲 戰機少女VII )。Banner题图为博主亲自合成所得。
    • 原主题 Miria 角色是 赤城みりあ (赤城米莉亚),出自游戏、动画和漫画 アイドルマスター シンデレラガールス (偶像大师 灰姑娘女孩)系列。
  2. 针对境内访问用户,对网站作了以下调整优化:
    • 新增CDN加速节点CN2( https://cn2.chionlab.moe ),保留原加速节点CloudFlare( https://blog.chionlab.moe )。对于境内和境外(或科学上网)用户,在访问本站时会自动切换。当然,你也可以在本站顶部手动切换CDN加速线路。
    • 针对境内用户无法访问Disqus的问题,新增多说评论模块。CN2节点默认屏蔽Disqus评论模块,只保留多说评论模块;原CloudFlare节点则同时保留Disqus和多说两个评论模块。问题:多说模块请求了第三方http资源,因此在访问本站文章时浏览器可能会报安全策略warning,但不影响体验。
    • 将Google Fonts的 Source Code Pro font face本地化,提高字体资源加载速度。

以上调整各处,除了CDN节点部署,均通过修改hexo和hexo主题源码完成,若有必要,以后将发表博文以提供修改思路和指点。

TCP keepalive的探究 (2) : 浏览器的Keepalive机制

上文介绍了TCP Keepalive机制以及其在linux中的编程实现,本文将继续介绍这种机制在浏览器中的运用,并以Chrome为例。

HTTP1.1中的Connection: Keep-Alive

在介绍Chrome对TCP Keepalive的实现之前,我们先来了解一下第七层协议HTTP1.1中的Connection字段。注意,本章节讨论的Keepalive为七层协议(HTTP1.1)中的Keep-Alive机制。

HTTP1.1协议头(header)中的Connection字段可取这两个值的其中之一:keep-alive, close
该字段在请求头(request header)和响应头(response header)中都可以存在,这说明,客户端可以申请开启Keep-Alive,而服务端可以接受Keep-Alive请求,或者拒绝并在响应头中告知客户端。

作用机理

这里以一次完整的HTTP1.1网站访问来说明。

  1. 客户端浏览器向 www.bilibili.com:80 建立TCP连接,并在此TCP连接上传输七层报文,请求GET /index.html资源,在请求头中,Connection置为keep-alive
  2. 服务端向浏览器返回index.html的文件内容,响应报头中Connection置为keep-alive,随后,不关闭和客户端的TCP连接
  3. 客户端复用该TCP连接,并请求GET /style.css资源,请求头置Connectionkeep-alive
  4. 服务器向浏览器返回index.css文件内容,仍然不关闭该TCP连接。
  5. 客户端继续复用该TCP连接请求多个同域资源。
  6. 客户端所需的各种资源都请求完毕,但是因为客户端的最后一次资源请求头中仍置Connectionkeep-alive,该TCP连接仍未被关闭。
  7. 如果在一段时间(通常是3分钟左右)内客户端没有使用该TCP连接请求资源,服务器可能会关闭该连接。连接被关闭后,客户端需要重新向该域建立TCP连接才能继续请求数据。
HTTP1.1的请求示意图 一次HTTP1.1的请求和响应报头

几点细节

  • HTTP1.1的Keep-Alive机制仅对同域下的网络请求有效。比如,对于http://www.bilibili.com/index.htmlhttp://www.bilibili.com/style.css这两个资源请求,浏览器能够复用其TCP连接,而对于非同域下的http://space.bilibili.com/index.html,则需要重新建立一次TCP连接。

  • 服务器有权拒绝客户端的Keep-Alive请求,在响应头中置Connectionclose,并在传输一次完整的响应报文后主动关闭TCP连接,在这之后,客户端如需向该域请求资源,则需重新建立TCP连接。而事实上,即使客户端和服务端都开启了Keep-Alive,服务端一般会主动关闭非活动的连接,否则会造成资源浪费。

  • Keep-Alive虽然可以在一定程度上通过复用TCP连接来提高页面资源加载性能,但是受HTTP1.1的max-connection限制,提高的性能很有限。很多时候,为了加快更多资源的加载,通常会使用多个不同域名的CDN。而在HTTP2中,通过二进制数据帧的方式来传输同域下多资源,可以解决这个问题。关于HTTP2的传输机制,可以参考这篇文章

Chrome对TCP连接的保活机制

上篇章节中我们熟悉了七层协议中HTTP1.1的Keep-Alive机制,本章节我们介绍Chrome对四层协议的TCP Keepalive的实现。

Chrome何时需要启用TCP Keepalive?
假定服务器启用了HTTP1.1 Keep-Alive,浏览器与服务器建立TCP连接,并在该TCP连接上有序地传输多个HTTP1.1七层报文,以此来请求多个资源。对于同域下,在浏览器完成一次请求并获得对应资源后,若一段时间内暂时未有新的资源请求(资源请求可能由页面JavaScript发出,如Ajax),直至下次请求发出前,该TCP连接保持空闲状态。而在这段空闲时间内,浏览器需要对该TCP连接进行保活。

下面我们将通过Wireshark抓包来验证。

Wireshark抓到的Chrome发出的TCP keepalive探测包

从上面的抓包结果中看到,在服务器返回完整HTTP 200报文的45秒后(Time=72),本地发出了第一个TCP Keepalive探测包并收到来自服务器的ACK。

这说明,Chrome对于可复用的TCP连接,采用的保活机制是TCP层(传输层)自带的Keepalive机制,通过TCP Keepalive探测包的方式实现,而不是在七层报文上自成协议来传输其它数据。

而实际上,由于HTTP1.1对时序和报文的约定,浏览器也不可在七层实现保活。假设,客户端在通过HTTP1.1获取一次资源后,若在这个TCP连接上发送一个0x70(无意义的数据,在七层实现保活的方式大多如此),服务器会在应用层接收到并缓存该数据,一段时间后客户端发送有效的HTTP请求报头,则服务端CGI应用程序收到的数据是0x70再接上一段HTTP请求头,这被认为是无效的HTTP报文,服务器则会返回400响应头,告知客户端这是坏的请求(Bad Request)。

所以,浏览器在处理HTTP1.1请求所对应的TCP连接的保活时,通过使用TCP Keepalive机制,来避免污染七层(应用层)的传输数据。

待续

本篇主要介绍浏览器对TCP Keepalive的运用,内容简单。结合本篇内容,作者将在下篇文章中详细说明作者在使用shadowsocks浏览web时遇到的问题、解决方案以及一点思考。

TCP keepalive的探究 (1) : NAT和保活机制

关于应用层的TCP连接保活(Keepalive)机制,相信大家都听说过。对于长连接TCP保活,典型的方法是发送应用层的心跳包,但这将增加开发人员的工作量:需要专门为心跳包制定协议。而在Linux的socket通信API中,自带了TCP_KEEPALIVE的相关参数设定,通过这种方式实现TCP长连接保活,无需修改原程序的逻辑,开发人员不需要关心心跳包的实现。本系列文章将从路由器NAT原理、keepalive基本的代码实现、浏览器保活机制、存在的问题几个方面逐步深入探究。

NAT

为什么要使用TCP keepalive?这得从NAT(地址转换)原理开始讲起。狭义上,NAT分为SNAT(原地址转换)和DNAT(目标地址转换),关于DNAT,有兴趣的同学可以自行查阅,本文只讨论SNAT。

我们都知道,路由器的最基本功能是对第三层(网络层)上的IP报文进行转发。实际上,路由器还有很关键的一个功能,这便是NAT。特别是对于ISP对普通用户链路上的路由器,NAT功能尤为重要。

为什么要使用NAT?原因很简单:IPv4地址非常稀缺。上网需求庞大,这使得ISP不可能为每一个入网用户都提供一个独立的公网IP,因此通常情况下,ISP会把用户接入局域网,使得多个用户共享同一个公网IP,而每一个用户各分得一个局域网内网IP。而连接公网和局域网的这台路由器,称之为网关(gateway),NAT的过程就发生在这台网关路由器上。

三层地址转换

局域网内的主机向公网发出的网络层IP报文,将经由网关被转发至公网,而在该转发过程中发生了地址转换。网关将该IP报文中的 源IP地址 从”该主机的内网IP”修改为”网关的公网IP”。

比如,局域网主机获得的内网IP为192.168.1.100,网关的公网IP为210.177.63.2,局域网主机向公网目标主机发出的IP报文中,源IP字段数据为192.168.1.100,在经过网关时,该字段数据将被修改为210.177.63.2

为什么要这么做,相信大家已经猜到了。公网上的目标主机在收到这个IP报文后,需要知道这个IP报文的来源地址,并向该来源地址发送响应报文,但如果不经过NAT,目标主机拿到的来源地址是192.168.1.100,这显然是一个公网上不可访问到的私有地址,目标主机无法将响应报文发送到正确的来源主机上。开启了NAT之后,IP报文的来源地址被网关修改为210.177.63.2,这是一个公网地址,目标主机将向这个地址(即网关路由器的公网地址)发送响应报文。

但是请注意,如果这个IP报文的数据段不含传输层协议报文,而是一个pure的网络层packet,来自目标主机的响应报文是不能被网关准确转发到多台局域网主机中的其中一台的。(ICMP报文除外,其报头中有Identifier字段用于标识不同的主机或进程,网关在处理Identifier时类似于下面提到的运输层端口)

传输层端口转换表

在三层地址转换中,我们可以保证局域网内主机向公网发出的IP报文能顺利到达目的主机,但是从目的主机返回的IP报文却不能准确送至指定局域网主机(我们不能让网关把IP报文广播至全部局域网主机,因为这样必然会带来安全和性能问题)。为了解决这个问题,网关路由器需要借助传输层端口,通常情况下是TCP或UDP端口,由此来生成一张端口转换表。

让我们通过一个实例来说明端口转换表如何运作。
假设局域网主机A192.168.1.100需要与公网上的目标主机B210.199.38.2:80进行一次TCP通信。其中A所在局域网的网关C的公网IP地址为210.177.63.2。步骤如下:

1. 局域网主机A192.168.1.100发出TCP连接请求,A上的TCP端口为系统分配的53600。该TCP握手包中,包含源地址和端口192.168.1.100:53600,目的地址和端口210.199.38.2:80
2. 网关C将该包的原地址和端口修改为210.177.63.2:63000,其中63000是网关分配的临时端口。
3. 网关C在端口转换表中增加一条记录:

内网主机IP 内网主机端口 网关端口 目的主机IP 目的主机端口
192.168.1.100 53600 63000 210.199.38.2 80

4. 网关C将修改后的TCP包发送至目的主机B。
5. 目的主机B收到后,发送响应TCP包。该响应TCP包含有以下信息:源地址和端口210.199.38.2:80,目的地址和端口210.177.63.2:63000
6. 网关C收到这个来自B的响应包后,随即在端口转换表中查找记录。该记录须符合以下条件:目的主机IP==210.199.38.2,目的主机端口==80,网关端口==63000
7. 网关C搜索到这条记录,记录显示内网主机IP为192.168.1.100,内网主机端口为53600
8. 网关C将该包的目的地址和端口修改为192.168.1.100:53600
9. 网关C随即将该修改后的TCP包转发至192.168.1.100:53600,即局域网主机A。此时运输层数据的一次交换已完成。

问题所在

在网关C上,由于端口数量有限(0~65535),端口转换表的维护占用系统资源,因此不能无休止地向端口转换表中增加记录。对于过期的记录,网关需要将其删除。如何判断哪些是过期记录?网关认为,一段时间内无活动的连接是过期的,应定时检测转换表中的非活动连接,并将之丢弃。而这个丢弃的过程,网关不会以任何的方式通告该连接的任何一端。

那么问题就来了:如果一个客户端应用程序由于业务需要,需要与服务端维持长连接(如TCP聊天程序),而如果在特别长的时间内(在博主的ISP环境下,该时间在3分钟左右),这个连接没有任何的数据交换,网关会认为这个连接过期并将这个连接从端口转换表中丢弃。该连接被丢弃时,客户端和服务端对此是完全无感知的。在连接被丢弃后,客户端将收不到服务端的数据推送,客户端发送的数据包也不能到达服务端。

第一次实验

让我们使用TCP测试工具netcat来实际实验一下。

  • 在公网服务器上,使用nc -l 9999命令监听TCP端口9999
  • 在局域网主机上,使用nc XX.XX.XX.XX 9999命令连接到这台公网服务器的9999端口。
  • 进行基本的双向发包测试。
  • 不关闭连接,在空闲5分钟后再进行双向发包测试。

在我的例子中,在双方建立TCP连接后,客户端(局域网主机)发送一行hello from client,服务端发送一行hello from server
等待5分钟,然后客户端发送一行test from client

通过wireshark在客户端主机上抓包,跟踪这个TCP连接得出如下结果:
1.png

从上图可得出:

  • 在第144秒时,通过TCP三次握手,双方建立连接。
  • 随后双方各发一行hello信息,并都成功接收到ACK响应包,证明发送成功。
  • 在第500秒时,客户端发送test from client,但是没有收到对方响应ACK,导致客户端多次重发(TCP Retransmission),但是仍然收不到ACK。

在服务端上,仅能收到客户端一开始发送的hello from client,5分钟后客户端发送的test from client并不能收到:
2.png

而在服务端尝试发送test from server,客户端也收不到了。

这表明,在这空闲的5分钟内,网关路由器已经“掐断”了这个TCP连接,导致5分钟后该连接不可再用。但无论是客户端还是服务端,都不知道这个连接已经作废了,因此客户端在发包没有收到ACK后仍在尝试重发,双方的netcat进程仍然没有退出,说明了网关在掐断连接时并没有通知双方。

TCP Keepalive

如果我们的业务需要我们维持长连接,这就要避免网关“干掉”我们的长连接。解决方法就是,让网关认为我们的TCP连接是活动的。在应用层,我们可以通过定时发送心跳包的方式实现。而如果使用Linux提供的TCP_KEEPALIVE,在应用层我们可完全不关心心跳包何时发送、发送什么内容,这一切由操作系统自动管理:操作系统会在该TCP连接上定时发送探测包,探测包既能像心跳包一样起到连接保活的作用,也能自动检测连接的有效性,并自动关闭无效连接。

在Linux全局内核设置中,有以下三个参数:

1
2
3
4
5
6
# cat /proc/sys/net/ipv4/tcp_keepalive_time
7200
# cat /proc/sys/net/ipv4/tcp_keepalive_intvl
75
# cat /proc/sys/net/ipv4/tcp_keepalive_probes
9

  • tcp_keepalive_time: 如果在该时间内没有数据往来,则发送探测包。
  • tcp_keepalive_intvl: 探测包发送间隔时间。
  • tcp_keepalive_probes: 尝试探测的次数。如果发送的探测包次数超过该值仍然没有收到对方响应,则认为连接已失效并关闭连接。

TCP Keepalive默认是关闭的。要启用这个特性,需要在程序中如下设置(代码实例来自Linux下TCP keepalive属性的表现):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/tcp.h>
int keepAlive = 1; // 开启keepalive属性
int keepIdle = 60; // 如该连接在60秒内没有任何数据往来,则进行探测
int keepInterval = 5; // 探测时发包的时间间隔为5 秒
int keepCount = 3; // 探测尝试的次数.如果第1次探测包就收到响应了,则后2次的不再发.
setsockopt(rs, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
setsockopt(rs, SOL_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle)); //对应tcp_keepalive_time
setsockopt(rs, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval)); //对应tcp_keepalive_intvl
setsockopt(rs, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount)); //对应tcp_keepalive_probes

如果省略TCP_KEEPIDLETCP_KEEPINTVLTCP_KEEPCNT三个属性的设置,将使用上文的三个系统全局默认值。

第二次实验

这次我们使用 netcat-keepalive 来测试。这个Github上的开源项目在netcat的基础上加入了上述的代码。参数说明请参照README。

测试方法基本不变。唯一的不同之处是,客户端使用netcat-keepalive,并开启TCP Keepalive特性。

客户端上的测试结果和wireshark抓包记录如下:
4.png

抓包记录显示,在空闲的5分钟内,客户端每隔30秒发送一个TCP探测包(TCP Keep-Alive),并收到服务端ACK(TCP Keep-Alive ACK)。在5分钟后客户端发送test from client,服务端发送test from server,均发送成功。

服务端上的截图:
服务端收到了来自客户端的“test from client”

这证明,我们通过TCP Keepalive,成功地阻止了网关路由器丢弃我们的TCP长连接,所以我们在5分钟后仍能够使用这个长连接进行通信。

让我们来看看这个TCP Keep-Alive探测包是个什么东西:
5.png

由上图可看出,探测包是一个特殊的TCP包:它的长度为零,Flags位ACK置1,Options置为两个NOP,而它的端口信息和普通的TCP数据包是一样的。

对于服务端响应的TCP Keep-Alive ACK探测包,是由服务器操作系统发送的。实际上,在使用应用层TCP编程时,并不能收到这个探测包,所以服务端应用程序对该探测包是无感知的。

待续…

本文从NAT基本原理介绍了TCP Keepalive的原理和基本实现,在下篇文章中,我们将探究Chrome浏览器对于TCP保活的实现。

mixins是有害的(Mixins Considered Harmful)[下篇]

上篇

原文:Facebook React: Mixins Considered Harmful

Migrating from Mixins
Let’s make it clear that mixins are not technically deprecated. If you use React.createClass(), you may keep using them. We only say that they didn’t work well for us, and so we won’t recommend using them in the future.
Every section below corresponds to a mixin usage pattern that we found in the Facebook codebase. For each of them, we describe the problem and a solution that we think works better than mixins. The examples are written in ES5 but once you don’t need mixins, you can switch to ES6 classes if you’d like.
We hope that you find this list helpful. Please let us know if we missed important use cases so we can either amend the list or be proven wrong!

从Mixins迁移

有一点需要说明的是,从技术上来讲,mixins不是被弃用的。如果你在使用React.createClass(),你可以继续使用它们。我们只是说它们对我们而言不能很好地运用,并且我们不推荐在未来中继续使用它们。下面的每一章节对应了我们在Facebook代码库中发现的mixin的使用场景。对于每种情况,我们会说明问题所在,并展示我们认为比使用mixins更好的解决方案。示例都使用ES5编写,但当你不再需要mixins时,你可以随心所欲地切换到ES6 classes。
我们希望你能从这个列表中得到帮助。如果我们缺漏了一些比较重要的应用场景,请告知我们,因此我们能拓展这个列表,或者证明其中的部分是错误的。

Performance Optimizations
One of the most commonly used mixins is PureRenderMixin. You might be using it in some components to prevent unnecessary re-renders when the props and state are shallowly equal to the previous props and state:

性能优化

使用率最高的mixins之一是 PureRenderMixin 。你可能正在一些组件中使用它,当props和state跟上次的值是浅层相等时,可避免不必要的重渲染

1
2
3
4
5
6
7
8
var PureRenderMixin = require('react-addons-pure-render-mixin');
var Button = React.createClass({
mixins: [PureRenderMixin],
// ...
});

解决方案

To express the same without mixins, you can use the shallowCompare function directly instead:

为了达到相同的效果而不使用mixins,你可以直接使用shallowCompare

1
2
3
4
5
6
7
8
9
10
var shallowCompare = require('react-addons-shallow-compare');
var Button = React.createClass({
shouldComponentUpdate: function(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
},
// ...
});

If you use a custom mixin implementing a shouldComponentUpdate function with different algorithm, we suggest exporting just that single function from a module and calling it directly from your components.

We understand that more typing can be annoying. For the most common case, we plan to introduce a new base class called React.PureComponent in the next minor release. It uses the same shallow comparison as PureRenderMixin does today.

如果你使用一个自定义的mixin,以不同的算法实现 shouldComponentUpdate 方法,我们建议从模块中导出该单一的方法,并在你的组件中直接调用它。
我们理解频繁的编码是令人不快的。对于更普遍的情况,我们计划在下一个小版本发布中引入一个新的基类React.PureComponent。它将使用浅层对比算法,正如今天的PureRenderMixin

Subscriptions and Side Effects
The second most common type of mixins that we encountered are mixins that subscribe a React component to a third-party data source. Whether this data source is a Flux Store, an Rx Observable, or something else, the pattern is very similar: the subscription is created in componentDidMount, destroyed in componentWillUnmount, and the change handler calls this.setState().

订阅和副作用

我们遇到的第二种最常见的mixins类型是那些用来订阅React组件到第三方数据源的mixins。无论这些数据源是一个Flux Store,还是一个Rx Observable,抑或是其他的,该模式都是相似的:订阅在componentDidMount中产生,在componentWillUnmount中被销毁,而变更处理函数将调用 this.setState()

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
var SubscriptionMixin = {
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},
componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},
componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},
handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
}
};
var CommentList = React.createClass({
mixins: [SubscriptionMixin],
render: function() {
// Reading comments from state managed by mixin.
var comments = this.state.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});
module.exports = CommentList;

Solution

If there is just one component subscribed to this data source, it is fine to embed the subscription logic right into the component. Avoid premature abstractions.

If several components used this mixin to subscribe to a data source, a nice way to avoid repetition is to use a pattern called “higher-order components”. It can sound intimidating so we will take a closer look at how this pattern naturally emerges from the component model.

解决方案

如果只有一个组件被订阅到这个数据源,直接将订阅逻辑内嵌到该组件中不失为一个良策。避免草率的抽象。

如果多个组件都使用这个mixin来订阅到一个数据源,一个好的避免重复冗余的方法是使用一种被称为“高阶组件(higher-order components,又称HOC)”的模式。这听起来让人生畏,所以我们将仔细分析这个模式如何自然地套用到组件模型上。

Higher-Order Components Explained
Let’s forget about React for a second. Consider these two functions that add and multiply numbers, logging the results as they do that:

高阶组件的解释

让我们暂时忘记React。想想这两个实现相加和相乘的函数,通过这样来实现记录计算结果:

1
2
3
4
5
6
7
8
9
10
11
function addAndLog(x, y) {
var result = x + y;
console.log('result:', result);
return result;
}
function multiplyAndLog(x, y) {
var result = x * y;
console.log('result:', result);
return result;
}

These two functions are not very useful but they help us demonstrate a pattern that we can later apply to components.

Let’s say that we want to extract the logging logic out of these functions without changing their signatures. How can we do this? An elegant solution is to write a higher-order function, that is, a function that takes a function as an argument and returns a function.

Again, it sounds more intimidating than it really is:

这两个函数并不是十分有用,但它们可以帮助我们描述一个典型的模式,这个模式我们之后将把它应用到组件上。

假设我们想从这些函数中抽离记录逻辑而不修改它们的签名。如何做到这点?一个优雅的方案是,写一个更高阶的函数,这个更高阶的函数实际上是一个将函数作为其参数,并返回一个新函数的函数。

又一次,它听起来让人生畏,但实际上它是更简单的:

1
2
3
4
5
6
7
8
9
10
function withLogging(wrappedFunction) {
// Return a function with the same API...
return function(x, y) {
// ... that calls the original function
var result = wrappedFunction(x, y);
// ... but also logs its result!
console.log('result:', result);
return result;
};
}

The withLogging higher-order function lets us write add and multiply without the logging statements, and later wrap them to get addAndLog and multiplyAndLog with exactly the same signatures as before:

这个 withLogging 高阶函数让我们在实现相加和相乘逻辑时不需考虑记录逻辑,在这之后我们通过嵌套的方式来得到与之前签名一致的 addAndLogmultiplyAndLog

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function add(x, y) {
return x + y;
}
function multiply(x, y) {
return x * y;
}
function withLogging(wrappedFunction) {
return function(x, y) {
var result = wrappedFunction(x, y);
console.log('result:', result);
return result;
};
}
// Equivalent to writing addAndLog by hand:
var addAndLog = withLogging(add);
// Equivalent to writing multiplyAndLog by hand:
var multiplyAndLog = withLogging(multiply);

Higher-order components are a very similar pattern, but applied to components in React. We will apply this transformation from mixins in two steps.

As a first step, we will split our CommentList component in two, a child and a parent. The child will be only concerned with rendering the comments. The parent will set up the subscription and pass the up-to-date data to the child via props.

高阶组件是一种非常相似的模式,只不过它是应用在React组件上的而已。我们将这种转换应用到mixins上,只需要两步即可。

第一步,我们将CommentList组件分为子和父两部分。子组件只关心渲染评论,而父组件将设置订阅,并将最新的数据通过props传递到子组件上。

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
// This is a child component.
// It only renders the comments it receives as props.
var CommentList = React.createClass({
render: function() {
// Note: now reading from props rather than state.
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});
// This is a parent component.
// It subscribes to the data source and renders <CommentList />.
var CommentListWithSubscription = React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},
componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},
componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},
handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},
render: function() {
// We pass the current state as props to CommentList.
return <CommentList comments={this.state.comments} />;
}
});
module.exports = CommentListWithSubscription;

There is just one final step left to do.

Remember how we made withLogging() take a function and return another function wrapping it? We can apply a similar pattern to React components.

We will write a new function called withSubscription(WrappedComponent). Its argument could be any React component. We will pass CommentList as WrappedComponent, but we could also apply withSubscription() to any other component in our codebase.

This function would return another component. The returned component would manage the subscription and render with the current data.

We call this pattern a “higher-order component”.

The composition happens at React rendering level rather than with a direct function call. This is why it doesn’t matter whether the wrapped component is defined with createClass(), as an ES6 class or a function. If WrappedComponent is a React component, the component created by withSubscription() can render it.

只剩下最后一步了。

还记得我们如何使得withLogging()传入一个函数并返回另一个嵌套它的函数吗?我们可以将相似的模式应用到React组件上来。

我们将编写一个新的函数,叫做withSubscription(WrappedComponent)。它的参数可以是任意的React组件。我们将传递CommentList作为WrappedComponent,但我们也可以在我们的代码基中将withSubscription()应用到任意其他的组件上。

这个函数会返回另一个组件。返回的组件将会管理好订阅,并渲染包含数据的<WrappedComponent />

我们把这种模式称为一个“高阶组件”。

这种合成发生在React的渲染层,而不是通过一个直接的函数调用。这就是为什么无论内嵌的组件是由createClass()创建的,还是由ES6 class生成的,抑或是一个函数,都无关紧要了。如果WrappedComponent是一个React组件,通过withSubscription()创建的组件都能渲染它。

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
// This function takes a component...
function withSubscription(WrappedComponent) {
// ...and returns another component...
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},
componentDidMount: function() {
// ... that takes care of the subscription...
DataSource.addChangeListener(this.handleChange);
},
componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},
handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},
render: function() {
// ... and renders the wrapped component with the fresh data!
return <WrappedComponent comments={this.state.comments} />;
}
});
}

Now we can declare CommentListWithSubscription by applying withSubscription to CommentList:

现在我们可以通过应用withSubscriptionCommentList上来声明CommentListWithSubscription了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var CommentList = React.createClass({
render: function() {
var comments = this.props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
});
// withSubscription() returns a new component that
// is subscribed to the data source and renders
// <CommentList /> with up-to-date data.
var CommentListWithSubscription = withSubscription(CommentList);
// The rest of the app is interested in the subscribed component
// so we export it instead of the original unwrapped CommentList.
module.exports = CommentListWithSubscription;

Solution, Revisited
Now that we understand higher-order components better, let’s take another look at the complete solution that doesn’t involve mixins. There are a few minor changes that are annotated with inline comments:

解决方案,重现

现在我们能更好的理解高阶组件了,让我们来再看一次完整的、无需涉及mixins的解决方案。内联的注释有少量修改。

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
function withSubscription(WrappedComponent) {
return React.createClass({
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},
componentDidMount: function() {
DataSource.addChangeListener(this.handleChange);
},
componentWillUnmount: function() {
DataSource.removeChangeListener(this.handleChange);
},
handleChange: function() {
this.setState({
comments: DataSource.getComments()
});
},
render: function() {
// Use JSX spread syntax to pass all props and state down automatically.
return <WrappedComponent {...this.props} {...this.state} />;
}
});
}
// Optional change: convert CommentList to a functional component
// because it doesn't use lifecycle hooks or state.
function CommentList(props) {
var comments = props.comments;
return (
<div>
{comments.map(function(comment) {
return <Comment comment={comment} key={comment.id} />
})}
</div>
)
}
// Instead of declaring CommentListWithSubscription,
// we export the wrapped component right away.
module.exports = withSubscription(CommentList);

Higher-order components are a powerful pattern. You can pass additional arguments to them if you want to further customize their behavior. After all, they are not even a feature of React. They are just functions that receive components and return components that wrap them.

Like any solution, higher-order components have their own pitfalls. For example, if you heavily use refs, you might notice that wrapping something into a higher-order component changes the ref to point to the wrapping component. In practice we discourage using refs for component communication so we don’t think it’s a big issue. In the future, we might consider adding ref forwarding to React to solve this annoyance.

高阶组件是一个强大的模式。你可以给它们传递更多的参数,如果你想要进一步高度定制它们的行为。毕境,它们甚至不是React的特性之一。它们只是接受传入组件,并返回嵌套了传入组件的新组件的函数而已。

就像其它解决方案,高阶函数同样有他们的潜在风险。比如,如果你大量地使用refs(组件引用),你可能会发现,将任意组件嵌套进高阶组件里面时,内层组件的ref会被改变。在实践中我们不建议使用refs来实现组件间通信,所以我们不认为这是个大问题。在未来,我们将考虑引入ref重定向到React中来解决这个问题。

Rendering Logic
The next most common use case for mixins that we discovered in our codebase is sharing rendering logic between components.

Here is a typical example of this pattern:

渲染逻辑

在我们的代码库中,我们发现的下一个常见的mixins用例是组件间渲染逻辑的共享。

以下是这个模式的典型例子:

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
var RowMixin = {
// Called by components from render()
renderHeader: function() {
return (
<div className='row-header'>
<h1>
{this.getHeaderText() /* Defined by components */}
</h1>
</div>
);
}
};
var UserRow = React.createClass({
mixins: [RowMixin],
// Called by RowMixin.renderHeader()
getHeaderText: function() {
return this.props.user.fullName;
},
render: function() {
return (
<div>
{this.renderHeader() /* Defined by RowMixin */}
<h2>{this.props.user.biography}</h2>
</div>
)
}
});

Multiple components may be sharing RowMixin to render the header, and each of them would need to define getHeaderText().

多个组件可能共享了RowMixin来渲染行头,而每个这些组件都需要定义一个getHeaderText()方法。

Solution

If you see rendering logic inside a mixin, it’s time to extract a component!

Instead of RowMixin, we will define a component. We will also replace the convention of defining a getHeaderText() method with the standard mechanism of top-data flow in React: passing props.

Finally, since neither of those components currently need lifecycle hooks or state, we can declare them as simple functions:

解决方案

如果你看见了一个mixin里面含有渲染逻辑,那么是时候把它们抽离到组件中了!

我们将定义一个<Row>组件来取代RowMixin。我们也将会把借由定义一个getHeaderText()方法来实现转换的方式替换成React中标准的自顶向下数据流机制:传递props。

最后,因为这些组件现在都不再需要生命周期钩子和状态了,我们会把他们定义为简单的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function RowHeader(props) {
return (
<div className='row-header'>
<h1>{props.text}</h1>
</div>
);
}
function UserRow(props) {
return (
<div>
<RowHeader text={props.user.fullName} />
<h2>{props.user.biography}</h2>
</div>
);
}

Props keep component dependencies explicit, easy to replace, and enforceable with tools like Flow and TypeScript.

Props使得组件依赖保持显式、易于替换、对诸如Flow和TypeScript一类的工具更易执行。

Note:

Defining components as functions is not required. There is also nothing wrong with using lifecycle hooks and state—they are first-class React features. We use functional components in this example because they are easier to read and we didn’t need those extra features, but classes would work just as fine.

备注:
将组件定义为函数不是必需的。使用React的头等特性:生命周期钩子和状态也是没有任何错误的。我们在这个示例中使用函数式组件,因为它们可以更易于阅读,并且我们不需要那些另外的特性,但使用classes也是一样的效果。

Context
Another group of mixins we discovered were helpers for providing and consuming React context. Context is an experimental unstable feature, has certain issues, and will likely change its API in the future. We don’t recommend using it unless you’re confident there is no other way of solving your problem.

Nevertheless, if you already use context today, you might have been hiding its usage with mixins like this:

上下文(Context)

我们发现的另外一系列mixins是提供和消费React Context的辅助器。Context是一个实验性的不稳定特性,存在确定的缺陷,而且它的API在未来可能会被改变。我们不推荐使用它,除非你十分确定没有其他方法来解决你的问题。

尽管如此,如果你已经使用了context,你可能把它的使用隐藏在了mixins里,就像这样:

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
var RouterMixin = {
contextTypes: {
router: React.PropTypes.object.isRequired
},
// The mixin provides a method so that components
// don't have to use the context API directly.
push: function(path) {
this.context.router.push(path)
}
};
var Link = React.createClass({
mixins: [RouterMixin],
handleClick: function(e) {
e.stopPropagation();
// This method is defined in RouterMixin.
this.push(this.props.to);
},
render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});
module.exports = Link;

Solution
We agree that hiding context usage from consuming components is a good idea until the context API stabilizes. However, we recommend using higher-order components instead of mixins for this.

Let the wrapping component grab something from the context, and pass it down with props to the wrapped component:

解决方案

在context的API稳定之前,我们认为,将context的调用在组件中隐藏起来是个好主意。不过,我们推荐使用高阶组件来取代mixins来实现这点。

让外层组件从context中获取数据,并通过props传递到内层组件中:

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
function withRouter(WrappedComponent) {
return React.createClass({
contextTypes: {
router: React.PropTypes.object.isRequired
},
render: function() {
// The wrapper component reads something from the context
// and passes it down as a prop to the wrapped component.
var router = this.context.router;
return <WrappedComponent {...this.props} router={router} />;
}
});
};
var Link = React.createClass({
handleClick: function(e) {
e.stopPropagation();
// The wrapped component uses props instead of context.
this.props.router.push(this.props.to);
},
render: function() {
return (
<a onClick={this.handleClick}>
{this.props.children}
</a>
);
}
});
// Don't forget to wrap the component!
module.exports = withRouter(Link);

If you’re using a third party library that only provides a mixin, we encourage you to file an issue with them linking to this post so that they can provide a higher-order component instead. In the meantime, you can create a higher-order component around it yourself in exactly the same way.

如果你在使用一个只提供mixin的第三方库,我们建议你去提交一个issue,引用本文链接,让他们去做成高阶组件。在这期间,通过完全一样的方式,你可以自己动手围绕它做一个高阶组件。

Utility Methods
Sometimes, mixins are used solely to share utility functions between components:

通用方法

有时候,mixins仅仅是用作在组件间共享的通用工具函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var ColorMixin = {
getLuminance(color) {
var c = parseInt(color, 16);
var r = (c & 0xFF0000) >> 16;
var g = (c & 0x00FF00) >> 8;
var b = (c & 0x0000FF);
return (0.299 * r + 0.587 * g + 0.114 * b);
}
};
var Button = React.createClass({
mixins: [ColorMixin],
render: function() {
var theme = this.getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

Solution
Put utility functions into regular JavaScript modules and import them. This also makes it easier to test them or use them outside of your components:

解决方案

将通用的工具方法放入常规的JavaScript模块中,并引入它们。这同样使得测试和组件外调用变得简单:

1
2
3
4
5
6
7
8
9
10
11
12
var getLuminance = require('../utils/getLuminance');
var Button = React.createClass({
render: function() {
var theme = getLuminance(this.props.color) > 160 ? 'dark' : 'light';
return (
<div className={theme}>
{this.props.children}
</div>
)
}
});

Other Use Cases
Sometimes people use mixins to selectively add logging to lifecycle hooks in some components. In the future, we intend to provide an official DevTools API that would let you implement something similar without touching the components. However it’s still very much a work in progress. If you heavily depend on logging mixins for debugging, you might want to keep using those mixins for a little longer.

If you can’t accomplish something with a component, a higher-order component, or a utility module, it could be mean that React should provide this out of the box. File an issue to tell us about your use case for mixins, and we’ll help you consider alternatives or perhaps implement your feature request.

Mixins are not deprecated in the traditional sense. You can keep using them with React.createClass(), as we won’t be changing it further. Eventually, as ES6 classes gain more adoption and their usability problems in React are solved, we might split React.createClass() into a separate package because most people wouldn’t need it. Even in that case, your old mixins would keep working.

We believe that the alternatives above are better for the vast majority of cases, and we invite you to try writing React apps without using mixins.

其他用例

有时候,人们使用mixins来向一些组件添加选择性的生命周期钩子日志记录。在未来,我们计划提供一个官方的开发工具API来实现相似功能,而无需触碰组件代码。虽然这仍有大量正在进度中的工作需要完成。如果你十分依赖日志记录mixins来调试,你可能还要继续保持使用它们一段时间。

如果你借助一个组件、一个高阶组件、或者一个通用模块,仍然不能完成一些事情,这意味着React应该是难以完成这样的事情的。向我们提交一个issue,告诉我们你的mixins使用场景,我们会帮助你考虑可选的方案,或者是在未来实现你的新特性请求。

Mixins在传统感官中不是完全抛弃的。你可以通过React.createClass()继续使用它们,因为我们不会在未来修改它。最终,当ES6 classes得到更广泛的采用,并且它们在React中使用上的问题得到解决时,我们也许会将React.createClass()分离到独立的包之中,因为大多数人不再需要它。即使是在那样的情况下,你的老mixins仍然能够继续工作。

我们相信,以上所提到的可选方案对于绝大多数的场景是更好的选择,我们邀请你来尝试在不使用mixins的情况下编写React应用。

mixins是有害的(Mixins Considered Harmful)[上篇]

原文:Facebook React: Mixins Considered Harmful

“How do I share the code between several components?” is one of the first questions that people ask when they learn React. Our answer has always been to use component composition for code reuse. You can define a component and use it in several other components.

“我如何在多个组件(components)之间共享代码?”,这是React初学者的问题之一。我们的答案一直都是,通过组件组合的方法来实现代码复用。你可以定义一个组件,并在其它的组件中使用它。

It is not always obvious how a certain pattern can be solved with composition. React is influenced by functional programming but it came into the field that was dominated by object-oriented libraries. It was hard for engineers both inside and outside of Facebook to give up on the patterns they were used to.

通过组件组合的方式来解决某一种情况不总是显而易见的。React受函数式编程影响,但结果它成为了由面向对象库组成的存在。无伦是Facebook内部员工,还是非Facebook的程序员,抛弃以往的开发方式都是困难的。

To ease the initial adoption and learning, we included certain escape hatches into React. The mixin system was one of those escape hatches, and its goal was to give you a way to reuse code between components when you aren’t sure how to solve the same problem with composition.

为了让入门学习变得简单,我们引入了一些解决方案(原文“escape hatches”即逃生舱,此处语义为解决问题的一些trick)。Mixin系统是其中的一个方法,它的目的是,当你不知道如何通过组件组合来解决问题时,来给你一个方法来实现组件间的代码复用。

Three years passed since React was released. The landscape has changed. Multiple view libraries now adopt a component model similar to React. Using composition over inheritance to build declarative user interfaces is no longer a novelty. We are also more confident in the React component model, and we have seen many creative uses of it both internally and in the community.
In this post, we will consider the problems commonly caused by mixins. Then we will suggest several alternative patterns for the same use cases. We have found those patterns to scale better with the complexity of the codebase than mixins.

React发布后三年过去了,大环境发生了改变。大多数视图库现在都采用类似React的组件模型。通过多个组件在继承关系之上的组合来构建用户界面不再是一个新奇的方式。我们也对React的组件模型更加自信,并且在内部和社区中,都看到了许多具有创新性的使用方式。
在这篇文章中,我们会讨论由mixins造成的普遍问题。然后我们会提出一些同等情况下的可选替代方案。这些新的方案,在同等的代码复杂度下,比用mixins的可扩展性更好。

为什么说Mixins不好?

At Facebook, React usage has grown from a few components to thousands of them. This gives us a window into how people use React. Thanks to declarative rendering and top-down data flow, many teams were able to fix a bunch of bugs while shipping new features as they adopted React.

在Facebook,React的使用从少量的组件演变成上千的组件数量。这给我们看见了人们是如何使用React的。多亏于声明性的渲染和自上而下的数据流,很多团队能够在迁移项目到React的时候修复一些bug。

However it’s inevitable that some of our code using React gradually became incomprehensible. Occasionally, the React team would see groups of components in different projects that people were afraid to touch. These components were too easy to break accidentally, were confusing to new developers, and eventually became just as confusing to the people who wrote them in the first place. Much of this confusion was caused by mixins. At the time, I wasn’t working at Facebook but I came to the same conclusions after writing my fair share of terrible mixins.

但是,一个很难避免的情况是,一些代码在使用了React了之后逐渐降低了可读性。有时,使用React的开发团队中会出现一些人们不太愿意去触碰的组件,而这些组件在不同的项目中被使用了。这些组件太容易意外损坏,这不但困扰了新加入的开发者,最终也困扰了一开始编写这些组件的人。这些麻烦的问题大多是由mixins造成的。在那时,我还未在Facebook工作,但在使用了一系列糟糕的mixins之后,我也能得出跟现在一样的结论。

This doesn’t mean that mixins themselves are bad. People successfully employ them in different languages and paradigms, including some functional languages. At Facebook, we extensively use traits in Hack which are fairly similar to mixins. Nevertheless, we think that mixins are unnecessary and problematic in React codebases. Here’s why.

这并不代表mixins都是不好的。人们成功地在不同的语言和范例中应用了mixins,其中包括了一些函数式语言。在Facebook,我们大量使用了类似mixins的一些比较hack的实现方式。我们认为mixins在React中是不再必要的,而且是非常容易出问题的。接下来讨论这是为什么。

Mixins引入了隐性的依赖

Sometimes a component relies on a certain method defined in the mixin, such as getClassName(). Sometimes it’s the other way around, and mixin calls a method like renderHeader() on the component. JavaScript is a dynamic language so it’s hard to enforce or document these dependencies.
Mixins break the common and usually safe assumption that you can rename a state key or a method by searching for its occurrences in the component file. You might write a stateful component and then your coworker might add a mixin that reads this state. In a few months, you might want to move that state up to the parent component so it can be shared with a sibling. Will you remember to update the mixin to read a prop instead? What if, by now, other components also use this mixin?

有时候一个组件依赖一个在mixin中定义的确定的方法,比如getClassName()。有时候在另一个场景下,mixin在组件上调用了一个方法,比如renderHeader()。JavaScript是一种动态语言,所以去强制定义或者记录这些依赖是很困难的。
Mixins打破了一个通用的、通常是安全的假设:你可以通过在组件源码文件中搜索的方式来重命名一个方法或者一个状态的key。你写了一个具有状态的组件,然后你的组员加入了一个mixin来读取它的状态。过了一两个月,你想把这个状态挪到父组件上,来实现跟相邻组件共享。你会记得同时更新这个mixin的代码,把它改为读取prop吗?再如果,现在还有其它组件也使用了这个mixin?

These implicit dependencies make it hard for new team members to contribute to a codebase. A component’s render() method might reference some method that isn’t defined on the class. Is it safe to remove? Perhaps it’s defined in one of the mixins. But which one of them? You need to scroll up to the mixin list, open each of those files, and look for this method. Worse, mixins can specify their own mixins, so the search can be deep.
Often, mixins come to depend on other mixins, and removing one of them breaks the other. In these situations it is very tricky to tell how the data flows in and out of mixins, and what their dependency graph looks like. Unlike components, mixins don’t form a hierarchy: they are flattened and operate in the same namespace.

这些隐形的依赖使得新成员在现有代码基础上继续开发变得困难。一个组件的render()方法也许引用了一些不在本类中定义的方法,删除它们是否安全?也许它们定义在mixins中,但是在哪个里面呢?你需要滚动到mixin列表,打开每个mixin的源码,来找这些方法。更坏的是,mixins可以定义它们自己的mixins,所以这次查找是一次深度查找。
经常地,mixins还依赖其它的mixins,如果你删除其中之一,可能会波及到另外的。在这种情况下,说明数据如何在mixins流入流出就变得很棘手了,更别说画出它们之间的依赖关系图。不像组件,mixins不会构成继承链:它们是扁平化的,并在同一个命名空间中起作用。

Mixins造成命名冲突

There is no guarantee that two particular mixins can be used together. For example, if FluxListenerMixin defines handleChange() and WindowSizeMixin defines handleChange(), you can’t use them together. You also can’t define a method with this name on your own component.
It’s not a big deal if you control the mixin code. When you have a conflict, you can rename that method on one of the mixins. However it’s tricky because some components or other mixins may already be calling this method directly, and you need to find and fix those calls as well.

从没有保证说任意两个mixins可以在一起使用。比如,如果FluxListenerMixin定义了handleChange()WindowSizeMixin也定义了handleChange(),你就不能把它们拿在一块用。你也不能在你的组件中用这个名字来命名方法。
如果你能控制mixin的代码,那问题是不大的。当你遇到了命名冲突,你可以在其中的mixin中修改那个方法的名字。但是,如果有另外的mixins或是组件已经直接调用了这个方法,这就变得很棘手了,你需要同时找到和修复这些调用。

If you have a name conflict with a mixin from a third party package, you can’t just rename a method on it. Instead, you have to use awkward method names on your component to avoid clashes.
The situation is no better for mixin authors. Even adding a new method to a mixin is always a potentially breaking change because a method with the same name might already exist on some of the components using it, either directly or through another mixin. Once written, mixins are hard to remove or change. Bad ideas don’t get refactored away because refactoring is too risky.

如果你在使用一个第三方包的mixin时遇到了命名冲突,你就不能改它的方法名了。取而代之,你需要在你的组件中使用很蹩脚的方法名来避免冲突。
这样的情况对于mixin作者来说并没有好多少。加入一个新方法到mixin中总是一个潜在的风险,因为在已经使用了这个mixin的组件中,可能早就存在同名的方法了,无伦是直接调用还是通过其它mixin来调用。一旦mixins写好,就很困难去修改或者移除其中的东西。一些欠佳的实现方式得不到重构,因为重构的风险太大。

Mixins造成滚雪球式的复杂性

Even when mixins start out simple, they tend to become complex over time. The example below is based on a real scenario I’ve seen play out in a codebase.
A component needs some state to track mouse hover. To keep this logic reusable, you might extract handleMouseEnter(), handleMouseLeave() and isHovering() into a HoverMixin. Next, somebody needs to implement a tooltip. They don’t want to duplicate the logic in HoverMixin so they create a TooltipMixin that uses HoverMixin. TooltipMixin reads isHovering() provided by HoverMixin in its componentDidUpdate() and either shows or hides the tooltip.

虽然mixins是从简单开始的,但它们会随着时间变得越来越复杂。下面的例子是基于一个真实的情况。
一个组件需要一些状态来跟踪鼠标的悬浮(hover)。为了使这个逻辑可复用,你抽取了handleMouseEnter()handleMouseLeave()isHovering()方法到一个HoverMixin里。接下来,有人需要实现一个悬浮提示框(tooltip)。他们不想拷贝HoverMixin里的逻辑代码,因此创建了一个TooltipMixin,这个TooltipMixin引用了HoverMixinTooltipMixin在它的componentDidUpdate()中读取由HoverMixin提供的isHovering()来显示或者隐藏提示框。

A few months later, somebody wants to make the tooltip direction configurable. In an effort to avoid code duplication, they add support for a new optional method called getTooltipOptions() to TooltipMixin. By this time, components that show popovers also use HoverMixin. However popovers need a different hover delay. To solve this, somebody adds support for an optional getHoverOptions() method and implements it in TooltipMixin. Those mixins are now tightly coupled.
This is fine while there are no new requirements. However this solution doesn’t scale well. What if you want to support displaying multiple tooltips in a single component? You can’t define the same mixin twice in a component. What if the tooltips need to be displayed automatically in a guided tour instead of on hover? Good luck decoupling TooltipMixin from HoverMixin. What if you need to support the case where the hover area and the tooltip anchor are located in different components? You can’t easily hoist the state used by mixin up into the parent component. Unlike components, mixins don’t lend themselves naturally to such changes.

几个月后,有人想让这个提示框的弹出方向变得可配置。为了避免代码重复,他们添加了一个新的配置方法getTooltipOptions()TooltipMixin。在这时,需要弹出浮层的组件也使用了HoverMixin。但是浮层需要不同的鼠标悬浮延时。为了解决这个问题,有人添加并实现了一个配置方法getHoverOptions()TooltipMixin中。这两个mixins现在紧紧耦合在一起了。
如果没有新的需求,这样是没有问题的。但是这个方法的可扩展性并不强。如果你想在同一个组件里面支持显示多个提示框呢?你不能在一个组件里面定义两次同一个mixin。如果提示框需要在用户引导里自动弹出,而不是在鼠标悬浮时弹出呢?你想解耦TooltipMixinHoverMixin?祝你好运。如果你想让鼠标悬浮点和提示框锚点在不同的组件中呢?你不能轻易地将mixin使用的状态抬升到父组件中。不像组件,mixins在遇到这些改变时并不能很自然地交付。

Every new requirement makes the mixins harder to understand. Components using the same mixin become increasingly coupled with time. Any new capability gets added to all of the components using that mixin. There is no way to split a “simpler” part of the mixin without either duplicating the code or introducing more dependencies and indirection between mixins. Gradually, the encapsulation boundaries erode, and since it’s hard to change or remove the existing mixins, they keep getting more abstract until nobody understands how they work.
These are the same problems we faced building apps before React. We found that they are solved by declarative rendering, top-down data flow, and encapsulated components. At Facebook, we have been migrating our code to use alternative patterns to mixins, and we are generally happy with the results. You can read about those patterns below.

每个新需求让mixins变得越来越难以理解。随着时间,使用同一个mixin的组件之间的耦合度变得越来越高。任何新的功能都会同时被附加到所有使用了这个mixin的组件。没有方法去分离这个mixin的“更简单”的部分,除非去拷贝其中的代码,或者在mixins之间引入更多的依赖和奇技淫巧。逐渐地,原来的封装会瓦解,并且因为更改或者移除已经存在的mixins是困难的,它们会变得更抽象,直到没人理解它们是怎么工作的。
这些问题跟我们在React出来之前构建应用程序时遇到的问题是一样的。我们认为这些问题可以通过声明性的渲染、自上而下的数据流和组件封装来解决。在Facebook,我们已经将代码的实现方式从mixins迁移到了取而代之的模式,并且我们对结果很乐观。你可以继续阅读来了解我们的新模式。

OpenWRT下双WAN配置

晚上好。博主前段时间因沉迷CGSS和PS4游戏,长时间未更新博客,实在不好。现在正值暑假,博主在公司实习,今晚趁未加班,写一篇早就想写的openwrt路由器干货。

本文讲述如何在openwrt家用智能路由器上配置双WAN带宽叠加。

前提条件

  • 两条或更多的宽带,或者是支持单线多拨的宽带。
  • 已经安装MWAN3及luci图形化配置界面(Pandorabox固件默认已安装)。

VLAN配置

什么是VLAN?VLAN是在同一物理局域网内用于划分若干个不同广播域(子网)的技术,子网内的主机可以互相通信,不同子网的主机之间不可互相通信。
什么是VLAN ID?用于标识每个VLAN子网的ID。
为什么要划分VLAN?在OpenWRT下,接口是根据VLAN划分的,每个逻辑接口(interface)可对应一个VLAN ID作为物理接口,这将在后面的步骤中体现出来。

在openwrt的web配置页面上,进入 网络->交换机 (Network->Switch)。
默认情况下,已经分配的VLAN应该有1个或者2个。
通过插拔网线的方法,将配置页上的端口和路由器的物理RJ45接口对应上来。
在小米路由器mini上,默认分配如下两个vlan:

其中,VLAN1用作LAN,连接了除端口4以外的所有物理端口;VLAN2是默认的WAN,只连接端口4。(此处端口4即为小米路由器mini上的蓝色WAN RJ45物理端口)
注意,端口状态“不关联”(untagged),即该端口作为本VLAN成员,进行二层交换;若选择“关联”(tagged),端口之间通信无二层交换,而是冲突广播(hub方式)。

选择一个端口作为第二个WAN口的端口,在现有的VLAN配置中将其设置为“关”,然后新建一个VLAN,将该端口设置为“不关联”,其他端口设置为“关”,CPU设置为“关联”。注意,小米路由器mini有一个特殊的端口7,按照原有的两个VLAN,将其设置为“关联”即可。
如图,博主选择端口1来作为第二个WAN端口,在VLAN1中将其设置为“关”,并在新建的VLAN3中设置其为“不关联”。

保存即可。

新建WAN接口

进入 网络->接口,将当前WAN接口更名为WAN1,并添加一个新接口,命名为WAN2
WAN2的配置中,设置第二条宽带的拨号方式,在“物理设置”中选择刚才添加的VLAN3(eth0.3)。

重要
进入WAN1的编辑页,在“高级设置”中,勾选“使用默认网关”,填写“使用网关跃点”为40;
进入WAN2的编辑页,在“高级设置”中,勾选“使用默认网关”,填写“使用网关跃点”为41;

若有更多的WAN需要添加,方法类似,需要注意每个WAN接口的网关跃点必须不一样。

设置完成后,在接口总览中应该能看到两个WAN都成功获取到IP,如果是PPPoE方式,应该都已经拨号成功。

MWAN3配置

接下来需要通过MWAN3实现多WAN负载均衡。

进入 网络->负载均衡。

  • 接口配置
    进入 配置->接口。
    删除所有已有的默认接口。
    添加两个接口,分别为WAN1WAN2
    在接口详情的“跟踪的IP地址”中,可添加几个国内的主机IP作为检测接口是否上线的ping地址。当ping该IP多次超时后,即该接口视作下线。
    博主的固件版本下,这个跟踪功能并不好使,经常误判断接口下线,因此我清空了跟踪的IP地址,并视作接口始终上线。

  • 成员配置
    进入 配置->成员,删除所有已有的默认成员,添加两个成员,分别命名为wan_1, wan_2
    成员wan_1设置接口为WAN1,跃点数1,接口比重1;
    成员wan_2设置接口为WAN2,跃点数1,接口比重1;

  • 策略配置
    进入 配置->策略,添加一个策略balanced(或者编辑已有的balanced策略),使用的成员为wan_1, wan_2

  • 规则配置
    进入 配置->规则,保留已有的https规则。如果没有default_rule规则,则添加一条default_rule规则,目标地址设置为0.0.0.0/0,协议选择all,使用的策略为balanced,其他留空。

  • 保存并应用全部设置,此时应该能够实现双线负载均衡了。

至此,openwrt路由器上的双WAN配置实现带宽叠加已经完成了,可以测速看看了。

cron任务的locale问题

原文:Linux - cron での locale の挙動! - mk-mode BLOG

こんばんは。
Linux で、自分が作成したスクリプトがコンソール上では正常に動作するのに、 cron で定時起動させようとすると文字コードの関係でうまく日本語出力ができないことがあります。
以下、それについての備忘録です。

晚上好。
在Linux下,自己编写的(shell)脚本,在终端下手动运行是一切正常的。但是,由于字符编码的关系,当cron在试图以定时任务来执行该脚本时,日语文字却不能被正常输出。
以下是解决这一问题的备忘录。

0. 前提条件
CentOS 6.4 (32bit) での作業を想定。
cron は crontab -e ではなく、 /etc/cron.d/ ディレクトリ配下にファイルを設置する方法。
文字化けが起こるスクリプトは “UTF-8” でエンコードされていて、日本語出力を伴うことを想定。
(当然、日本語出力を伴わないのならロケールの心配もない)

0. 条件

  • 假定操作系统是CentOS 6.4 (32bit) (译者注:6.X, 64位同样适用)
  • 不使用cron的crontab -e,而是在/etc/cron.d/目录下建立配置文件来设置cron任务(译者注:同样适用于通过crontab -e设置的任务)
  • 脚本使用UTF-8编码,并假定脚本的执行将伴随有日语文字输出,且(由cron执行时)出现了乱码。
    (当然,如果日语输出不受locale影响,则无需担心。)

1. cron 外(コンソール)でのロケール
普通にコンソールで locale コマンドでロケールを確認してみる。

1. 在cron外部(用户终端)的locale

在一般的用户终端(console)中,尝试通过locale命令来确认当前环境的locale。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# locale
LANG=ja_JP.UTF-8
LC_CTYPE="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_PAPER="ja_JP.UTF-8"
LC_NAME="ja_JP.UTF-8"
LC_ADDRESS="ja_JP.UTF-8"
LC_TELEPHONE="ja_JP.UTF-8"
LC_MEASUREMENT="ja_JP.UTF-8"
LC_IDENTIFICATION="ja_JP.UTF-8"
LC_ALL=

2. cron 内でのロケール
次に cron 内で locale コマンドを実行させてみる。
例えば、以下のようなファイル /etc/cron.d/locale_test を作成してみる。

2. cron内的locale

接下来,我们尝试在cron内执行locale命令。(译者注:其实就是在cron job中运行locale命令)
如下例,尝试创建一个文件/home/hoge/work/locale.log

1
* * * * * root locale > /home/hoge/work/locale.log

毎分 “/home/hoge/work/” ディレクトリ内に “locale.log” というファイルが作成されるので、内容を確認してみる。

每分钟,/home/hoge/work/下的locale.log文件都会被写入新数据,我们来尝试确认该文件内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LANG=
LC_CTYPE="POSIX"
LC_NUMERIC="POSIX"
LC_TIME="POSIX"
LC_COLLATE="POSIX"
LC_MONETARY="POSIX"
LC_MESSAGES="POSIX"
LC_PAPER="POSIX"
LC_NAME="POSIX"
LC_ADDRESS="POSIX"
LC_TELEPHONE="POSIX"
LC_MEASUREMENT="POSIX"
LC_IDENTIFICATION="POSIX"
LC_ALL=

“ja_JP.UTF-8” でなく “POSIX” となっている。
これでは、UTF-8 でエンコードされているスクリプトは日本語表示で不具合を起こすでしょう。

ja_JP.UTF-8并不在POSIX集合内。
因此,使用UTF-8编码的脚本在遇到日语输出时会出错。

3. 対処方法
cron 内で UTF-8 でデンコードされたスクリプトを実行させる場合は、以下のように LC_CTYPE, LANG を設定してやる。

3. 解决方法

要在cron中运行通过UTF-8编码的脚本,需要设定LC_CTYPELANG。如下:

1
2
3
4
LC_CTYPE="ja_JP.utf8"
LANG="ja_JP.utf8"
* * * * * root locale > /home/hoge/work/locale.log

再度 “/home/hoge/work/” ディレクトリ内の “locale.log” の内容を確認してみる。

再次确认/home/hoge/work/目录下的locale.log文件的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LANG="ja_JP.utf8"
LC_CTYPE="ja_JP.utf8"
LC_NUMERIC="ja_JP.utf8"
LC_TIME="ja_JP.utf8"
LC_COLLATE="ja_JP.utf8"
LC_MONETARY="ja_JP.utf8"
LC_MESSAGES="ja_JP.utf8"
LC_PAPER="ja_JP.utf8"
LC_NAME="ja_JP.utf8"
LC_ADDRESS="ja_JP.utf8"
LC_TELEPHONE="ja_JP.utf8"
LC_MEASUREMENT="ja_JP.utf8"
LC_IDENTIFICATION="ja_JP.utf8"
LC_ALL=

“ja_JP.utf8” になりました。(UTF-8 と utf8 の違いはあるが問題ない)
これで、日本語出力で文字化けすることがなくなります。

现在是ja_JP.utf8了。(UTF-8和utf8的区别并不是个问题)
现在,(cron job任务的)的日语输出不会再乱码了。

4. 参考
上記では任意のスクリプトについて話したが、UTF-8 エンコードの Ruby スクリプト(日本語出力を伴うもの)を cron 起動させるには以下のように -Ku オプションで文字コードを指定することでも対処可能である。

4. 参考

上面的记录是针对任意的脚本。若需通过cron运行含有日语输出的Ruby脚本,可以通过-Ku选项指定字符编码。如下:

1
* * * * * root /usr/local/bin/ruby -Ku test_script.rb

5. 後始末
当然、テストで作成した cron スクリプトは不要なので削除しておく。

以上。

5. 后续清理

当然,在刚才的测试中添加的cron任务脚本(locale命令)是不再需要的,请删除它。

译者注

本文locale问题的解决方案对于简体中文也是同样适用的,只需将本文中的ja_JP替换成zh_CN即可。

WeChatMomentStat:微信朋友圈导出工具开发记录

GitHub repo

https://github.com/Chion82/WeChatMomentStat-Android

关于WeChatMomentStat-Android

博主之前开发过WeChatMomentExport,借助Xposed实现了导出微信朋友圈数据。该项目在GitHub上获得了不少Star,被应用平台收录之后也有几千的下载量,可见这个需求是存在的。但是,对于WeChatMomentExport,还存在以下问题:

  • 作为Xposed模块,必需依赖Xposed才能运行
  • 因为数据抓取方式为hook,故用户需要在微信朋友圈页面手动下滑加载
  • 微信版本每更新一次会导致源码被重新混淆,相应的本项目也需要更新钩子逻辑
  • 项目的定位是将导出数据作为开发者二次开发所需的数据源,但从酷安网的用户评论看,普通用户不能理解需求

对于上述问题,博主考虑了以下相应对策:

  • 上次的逆向分析结果看,只要想办法调用到这几个类(以下称为parser),就可以解析微信SQLite缓存中的blob数据,这样就不需要借助Xposed的hook了,也能实现一键导出
  • 考虑到blob格式不会经常变更,因此可在项目中整合parser,这样本项目就无需经常更新
  • 博主在开发WeChatMomentExport之后随手写的朋友圈数据统计脚本也获得了少量star,因此认为,对于普通用户,生成这样的简易统计数据更有吸引性

于是,决定整合WeChatMomentExport和统计脚本,做一个功能稍完善的工具。

几个技术难点

要做这样的一个独立的APP,而不是一个Xposed模块,需要解决以下问题:

  1. 如何在APP中整合parser?parser的逻辑代码被混淆在微信的dex中,直接分析其算法难度太大。
  2. 如何越权获得微信的SQLite缓存数据?
  3. 如何确保从SQLite缓存中取得的朋友圈数据足够齐全?

经过查阅各种文档和亲自实验,还是找到了解决方案。

使用DexClassLoader直接加载微信apk中的parser

DexClassLoader可直接解析apk中的classes.dex,并从中取得所需类,通过java反射,可以获得所需的parser方法。因此,无需再分析parser算法,而是直接调用就可以了。
通过DexClassLoader取得parser方法的关键代码如下:

1
2
3
4
5
6
7
8
9
10
DexClassLoader cl = new DexClassLoader(
apkFile.getAbsolutePath(), //apkFile为微信apk文件
context.getDir("outdex", 0).getAbsolutePath(),
null,
ClassLoader.getSystemClassLoader());
Class SnsDetailParser = cl.loadClass("com.tencent.mm.plugin.sns.f.i");
Class SnsDetail = cl.loadClass("com.tencent.mm.protocal.b.atp");
Class SnsObject = cl.loadClass("com.tencent.mm.protocal.b.aqi");
//之后只需使用java反射即可取得所需方法

还需要提供一个微信的apk文件。因此将微信apk放在assets中,首次运行本工具的时候释放到外部存储中。

通过su调用,拷贝微信的SQLite数据库文件

需要越权操作的话,获取root权限是很难避免的。通过调用su,可以复制出微信的SQLite数据库文件到本工具可读写的目录下。
微信朋友圈的SQLite文件在/data/data/com.tencent.mm/MicroMsg/XXXXXXXXXXXXX/SnsMicroMsg.db。其中,XXXXXXXXXXXXX是微信生成的hash值,每台设备上都可能不一样。由于在Android的shell中没有find或类似的命令,需要复制出这个SnsMicroMsg.db还得费一点功夫。最终,博主采用ls列目录并循环尝试cp的方法强行取得SnsMicroMsg.db

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void copySnsDB() throws Throwable {
String dataDir = Environment.getDataDirectory().getAbsolutePath();
String destDir = Config.EXT_DIR;
Process su = Runtime.getRuntime().exec("su");
DataOutputStream outputStream = new DataOutputStream(su.getOutputStream());
outputStream.writeBytes("mount -o remount,rw " + dataDir + "\n");
outputStream.writeBytes("cd " + dataDir + "/data/" + Config.WECHAT_PACKAGE + "/MicroMsg\n");
outputStream.writeBytes("ls | while read line; do cp ${line}/SnsMicroMsg.db " + destDir + "/ ; done \n");
outputStream.writeBytes("sleep 1\n");
outputStream.writeBytes("chmod 777 " + destDir + "/SnsMicroMsg.db\n");
outputStream.writeBytes("exit\n");
outputStream.flush();
outputStream.close();
Thread.sleep(1000);
}

其中,还需要修改db文件的权限为777,否则工具无权读取数据库。另外,sleep是为了避免稍后偶然性出现的读取数据库失败的情况(可能文件复制不完整或未被去锁?)。

关于SQLite中数据完整性的问题

经过测试,微信的SQLite数据库中缓存了几乎所有加载过的朋友圈,理论上应当不会漏数据。

题外话

本来这个app计划于2月中旬就写出来的,由于博主不是安卓开发者,没有系统地学过安卓开发,当时还不知道有DexClassLoader,写的第一个demo用的依然是Xposed,但是不同于WeChatMomentExport,这里用Xposed仅仅是为了取得那几个parser的类而已。2月底开学后,通过各种渠道了解到了DexClassLoader,才有现在的这个思路。
博主现在读大二,这学期开学后课程比较紧张,再者在工作室外包项目的压力下(团队管理问题,还有涉及的利益问题出现冲突的时候,处理起来非常棘手),一时失去了搞开源轮子的动力,甚至连续一个月都没有更新博客,于是才导致了这个项目拖到现在才基本完成。
看到了GitHub上的项目star和follower每隔几天就多一个,本站也陆续有网友来评论,每日UV也保持在100以上,就重拾了动力去继续折腾。
非常感谢前来光临本站和GitHub profile的各位!