简介
一个实时应用程序能够使用户***时间了解他想了解的信息。用户不必不停地刷新用户界面来获取***的消息更新,应用程序的服务器端会自动更新客户端应用的。在本文中,我们将使用时下流行的RethinkDB +React Native框架开发一个真正实时的移动Web应用程序。
注意,阅读本文的前提是假定你已经了解了有关React Native编程的基础知识,因此,示例程序中有些相关的细节代码在此并不想赘述。如果你还是一名初学者,那么建议你先读一下这个网址处的文章:https://www.sitepoint.com/build-android-app-react-native/。如果你想继续阅读本文的话,建议你首先下载本文的示例工程源码,地址是https://github.com/sitepoint-editors/rn-rethinkdb-socketio-newssharer。
下图给出示例工程的运行时快照。
下面,让我们首先来分析一下手机应用程序的编码情况,然后再来讨论服务器端组件相关编程,其中将使用到Node、Express、Socket.io和RethinkDB等技术。
安装依赖性
从你克隆下来的工程中导航到NewsShare目录下,然后执行命令npm install来安装一下下面这些工程依赖项:
1.react-native:这是React Native(本机)框架。
2.lodash:用于管理新闻项数组,以便通过票数来限制和排序该数组。
3.react-native-modalbox:用于创建模态对话框来共享一则新闻。
4.react-native-button:React Native模态对话框依赖于它,用于创建按钮。
5.react-native-vector-icons:用于使用流行图标集,如FontAwesome和Ionicons等来创建图标。这主要用于为投票按钮创建图标。
6.socket.io-client:Socket.io的客户端组件,它是一个实时应用程序框架。
链接图标
安装依赖关系后,还需要一个额外步骤就是使图标正常工作,即要将它们链接到应用程序。这是通过使用rnpm——React Native的软件包管理器实现的。
我们要使用npm来安装 rnpm,格式如下:
npm install rnpm -g
然后,就可以执行NewsSharer目录下的rnpm link命令来链接图标了。
开发移动客户端程序
下面给出的是文件index.android.js的内容:
- import React, { Component } from 'react';
- import {
- AppRegistry,
- StyleSheet,
- View
- } from 'react-native';
- import Main from './components/Main';
- class NewsSharer extends Component {
- render() {
- return (
- <View style={styles.container}>
- <Main />
- </View>
- );
- }
- }
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- backgroundColor: '#F5FCFF',
- }
- });
- AppRegistry.registerComponent('NewsSharer', () => NewsSharer);
该文件是Android应用程序的入口点文件。如果你想把它部署到iOS上,那么你可以把上述代码复制到文件index.ios.js中。
此文件的主要任务是导入Main组件,组件是应用程序的核心所在。当您导入组件而不是重复地为每个平台编码时,这可以大大减少编程的重复性。
编写主应用程序组件
在路径components/Main.js下创建文件Main.js,内容如下:
- import React, { Component } from 'react';
- import {
- AppRegistry,
- StyleSheet,
- Text,
- View,
- TextInput,
- TouchableHighlight,
- Linking,
- ListView
- } from 'react-native';
- import Button from 'react-native-button';
- import Modal from 'react-native-modalbox';
- import Icon from 'react-native-vector-icons/Octicons';
- import "../UserAgent";
- import io from 'socket.io-client/socket.io';
- import _ from 'lodash';
- var base_url = 'http://YOUR_DOMAIN_NAME_OR_IP_ADDRESS:3000';
- export default class Main extends Component {
- constructor(props){
- super(props);
- this.socket = io(base_url, {
- transports: ['websocket']
- });
- this.state = {
- is_modal_open: false,
- news_title: '',
- news_url: '',
- news_items_datasource: new ListView.DataSource({
- rowHasChanged: (row1, row2) => row1 !== row2,
- }),
- is_news_loaded: false,
- news: {},
- news_items: []
- };
- }
- getNewsItems(){
- fetch(base_url + '/news')
- .then((response) => {
- return response.json();
- })
- .then((news_items) => {
- this.setState({
- 'news_items': news_items
- });
- var news_datasource = this.state.news_items_datasource.cloneWithRows(news_items);
- this.setState({
- 'news': news_datasource,
- 'is_news_loaded': true
- });
- return news_items;
- })
- .catch((error) => {
- alert('Error occured while fetching news items');
- });
- }
- componentWillMount(){
- this.socket.on('news_updated', (data) => {
- var news_items = this.state.news_items;
- if(data.old_val === null){
- news_items.push(data.new_val);
- }else{
- _.map(news_items, function(row, index){
- if(row.id == data.new_val.id){
- news_items[index].upvotes = data.new_val.upvotes;
- }
- });
- }
- this.updateUI(news_items);
- });
- }
- updateUI(news_items){
- var ordered_news_items = _.orderBy(news_items, 'upvotes', 'desc');
- var limited_news_items = _.slice(ordered_news_items, 0, 30);
- var news_datasource = this.state.news_items_datasource.cloneWithRows(limited_news_items);
- this.setState({
- 'news': news_datasource,
- 'is_news_loaded': true,
- 'is_modal_open': false,
- 'news_items': limited_news_items
- });
- }
- componentDidMount(){
- this.getNewsItems();
- }
- upvoteNewsItem(id, upvotes){
- fetch(base_url + '/upvote-newsitem', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- news_id: id,
- upvotes: upvotes + 1
- })
- })
- .catch((err) => {
- alert('Error occured while trying to upvote');
- });
- }
- openModal(){
- this.setState({
- is_modal_open: true
- });
- }
- closeModal(){
- this.setState({
- is_modal_open: false
- });
- }
- shareNews(){
- fetch(base_url + '/save-newsitem', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- news_title: this.state.news_title,
- news_url: this.state.news_url,
- })
- })
- .then((response) => {
- alert('News was shared!');
- this.setState({
- news_title: '',
- news_url: ''
- });
- })
- .catch((err) => {
- alert('Error occured while sharing news');
- });
- }
- openPage(url){
- Linking.canOpenURL(url).then(supported => {
- if(supported){
- Linking.openURL(url);
- }
- });
- }
- renderNews(news){
- return (
- <View style={styles.news_item}>
- <TouchableHighlight onPress={this.upvoteNewsItem.bind(this, news.id, news.upvotes)} underlayColor={"#E8E8E8"}>
- <View style={styles.upvote}>
- <Icon name="triangle-up" size={30} color="#666" />
- <Text style={styles.upvote_text}>{news.upvotes}</Text>
- </View>
- </TouchableHighlight>
- <TouchableHighlight onPress={this.openPage.bind(this, news.url)} underlayColor={"#E8E8E8"}>
- <View style={styles.news_title}>
- <Text style={styles.news_item_text}>{news.title}</Text>
- </View>
- </TouchableHighlight>
- </View>
- );
- }
- render(){
- return (
- <View style={styles.container}>
- <View style={styles.header}>
- <View style={styles.app_title}>
- <Text style={styles.header_text}>News Sharer</Text>
- </View>
- <View style={styles.header_button_container}>
- <Button onPress={this.openModal.bind(this)} style={styles.btn}>
- Share News
- </Button>
- </View>
- </View>
- {
- this.state.is_news_loaded &&
- <View style={styles.body}>
- <ListView initialListSize={1} dataSource={this.state.news} style={styles.news} renderRow={this.renderNews.bind(this)}></ListView>
- </View>
- }
- <Modal
- isOpen={this.state.is_modal_open}
- style={styles.modal}
- position={"center"}
- >
- <View style={styles.modal_body}>
- <View style={styles.modal_header}>
- <Text style={styles.modal_header_text}>Share News</Text>
- </View>
- <View style={styles.input_row}>
- <TextInput
- style={{height: 40, borderColor: 'gray', borderWidth: 1}}
- onChangeText={(text) => this.setState({news_title: text})}
- value={this.state.news_title}
- placeholder="Title"
- />
- </View>
- <View style={styles.input_row}>
- <TextInput
- style={{height: 40, borderColor: 'gray', borderWidth: 1}}
- onChangeText={(text) => this.setState({news_url: text})}
- value={this.state.news_url}
- placeholder="URL"
- keyboardType="url"
- />
- </View>
- <View style={styles.input_row}>
- <Button onPress={this.shareNews.bind(this)} style={[styles.btn, styles.share_btn]}>
- Share
- </Button>
- </View>
- </View>
- </Modal>
- </View>
- );
- }
- }
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignSelf: 'stretch',
- backgroundColor: '#F5FCFF',
- },
- header: {
- flex: 1,
- backgroundColor: '#3B3738',
- flexDirection: 'row'
- },
- app_title: {
- flex: 7,
- padding: 10
- },
- header_text: {
- fontSize: 20,
- color: '#FFF',
- fontWeight: 'bold'
- },
- header_button_container: {
- flex: 3
- },
- body: {
- flex: 19
- },
- btn: {
- backgroundColor: "#0***5D1",
- color: "white",
- margin: 10
- },
- modal: {
- height: 300
- },
- modal_header: {
- margin: 20,
- },
- modal_body: {
- alignItems: 'center'
- },
- input_row: {
- padding: 20
- },
- modal_header_text: {
- fontSize: 18,
- fontWeight: 'bold'
- },
- share_btn: {
- width: 100
- },
- news_item: {
- paddingLeft: 10,
- paddingRight: 10,
- paddingTop: 15,
- paddingBottom: 15,
- marginBottom: 5,
- borderBottomWidth: 1,
- borderBottomColor: '#ccc',
- flex: 1,
- flexDirection: 'row'
- },
- news_item_text: {
- color: '#575757',
- fontSize: 18
- },
- upvote: {
- flex: 2,
- paddingRight: 15,
- paddingLeft: 5,
- alignItems: 'center'
- },
- news_title: {
- flex: 18,
- justifyContent: 'center'
- },
- upvote_text: {
- fontSize: 18,
- fontWeight: 'bold'
- }
- });
- AppRegistry.registerComponent('Main', () => Main);
下面来分析一下上面代码。首先,导入编程中所需要的内置的React Native及第三方组件:
- import React, { Component } from 'react';
- import {
- AppRegistry,
- StyleSheet,
- Text,
- View,
- TextInput,
- TouchableHighlight,
- Linking,
- ListView
- } from 'react-native';
- import Button from 'react-native-button';
- import Modal from 'react-native-modalbox';
- import Icon from 'react-native-vector-icons/Octicons';
- import "../UserAgent";
- import io from 'socket.io-client/socket.io';
- import _ from 'lodash';
注意,你使用如下方式导入了自己开发的另外文件中的代码:
- import "../UserAgent";
这是你在根目录NewsSharer下看到的UserAgent.js文件。它包含的代码用于设置用户代理为react-native——Socket.io需要这样做,或者它会假设程序运行于浏览器环境中。
- window.navigator.userAgent = 'react-native';
接下来,确定应用程序要请求的基URL。如果您要进行本地测试,这可能是您的计算机的内部IP地址。为了使这能够工作,你必须确保你的手机或平板电脑连接到与您的计算机位于同一网络。
- var base_url = 'http://YOUR_DOMAIN_NAME_OR_IP_ADDRESS:3000';
接下来,在构造函数中,初始化套接字连接:
- this.socket = io(base_url, {
- transports: ['websocket']
- });
然后,设置应用程序的默认状态:
- this.state = {
- is_modal_open: false, //for showing/hiding the modal
- news_title: '', //default value for news title text field
- news_url: '', //default value for news url text field
- //initialize a datasource for the news items
- news_items_datasource: new ListView.DataSource({
- rowHasChanged: (row1, row2) => row1 !== row2,
- }),
- //for showing/hiding the news items
- is_news_loaded: false,
- news: {}, //the news items datasource
- news_items: [] //the news items
- };
此函数的功能是使用内置的fetch方法从服务器端取回新闻项目。它向news路由发出GET请求,然后从响应中提取news_items对象。这个对象用于稍后创建客户端ListView组件所需的新闻数据源。一旦创建,它便使用新闻数据源更新状态;这样一来,用户界面新闻项内容也可以得到相应的更新。
- getNewsItems(){
- fetch(base_url + '/news')
- .then((response) => {
- return response.json();
- })
- .then((news_items) => {
- this.setState({
- 'news_items': news_items
- });
- var news_datasource = this.state.news_items_datasource.cloneWithRows(news_items);
- this.setState({
- 'news': news_datasource,
- 'is_news_loaded': true
- });
- return news_items;
- })
- .catch((error) => {
- alert('Error occured while fetching news items');
- });
- }
下面的ComponentWillMount方法是React的生命周期方法之一。这允许您可以在初始化渲染发生前执行你自己的定制代码。也正是在此处,你监听Socket.io的服务器组件发出的news_updated事件;而当此事件发生时,它可能是两件事中之一——或者当用户共享新闻项时或者当他们对现有新闻项投赞成票时。
值得注意的是,当出现新的新闻项时RethinkDB的changefeed将为old_val返回一个null值。这也正是我们区分上面两种可能性的办法。如果用户共享一个新闻项,那么将其推到news_items数组中;否则,查找投赞成票的新闻项并更新其赞成票计数。现在,您可以更新用户界面来反映所做的更改了。
- componentWillMount(){
- this.socket.on('news_updated', (data) => {
- var news_items = this.state.news_items;
- if(data.old_val === null){ //a new news item is shared
- //push the new item to the news_items array
- news_items.push(data.new_val);
- }else{ //an existing news item is upvoted
- //find the news item that was upvoted and update its upvote count
- _.map(news_items, function(row, index){
- if(row.id == data.new_val.id){
- news_items[index].upvotes = data.new_val.upvotes;
- }
- });
- }
- //update the UI to reflect the changes
- this.updateUI(news_items);
- });
- }
接下来,UpdateUI函数使用赞成票数按照从高到低订阅新闻项。一旦排序,便提取最前面的30条新闻,同时更新状态。
- updateUI(news_items){
- var ordered_news_items = _.orderBy(news_items, 'upvotes', 'desc');
- var limited_news_items = _.slice(ordered_news_items, 0, 30);
- var news_datasource = this.state.news_items_datasource.cloneWithRows(limited_news_items);
- this.setState({
- 'news': news_datasource,
- 'is_news_loaded': true,
- 'is_modal_open': false,
- 'news_items': limited_news_items
- });
- }
下面要介绍的ComponentDidMount方法是另一个React生命周期方法,此方法在初始渲染之后调用。在此方法中,我们实现从服务器端获取新闻项。
【注】,如果你想在安装组件之前发出请求的话,也可以从componentWillMount方法中从服务器端获取新闻项。
- componentDidMount(){
- this.getNewsItems();
- }
接下来的upvoteNewsItem方法将向服务器端发出一个投赞成票新闻项请求:
- upvoteNewsItem(id, upvotes){
- fetch(base_url + '/upvote-newsitem', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- news_id: id,
- upvotes: upvotes + 1
- })
- })
- .catch((err) => {
- alert('Error occured while trying to upvote');
- });
- }
接下来,openModal和closeModal方法分别负责显示与隐藏共享新闻内容的模态对话框。
- openModal(){
- this.setState({
- is_modal_open: true
- });
- }
- closeModal(){
- this.setState({
- is_modal_open: false
- });
- }
继续往下来,shareNews函数用于发送请求来创建一条新闻项:
- shareNews(){
- fetch(base_url + '/save-newsitem', {
- method: 'POST',
- headers: {
- 'Accept': 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- news_title: this.state.news_title,
- news_url: this.state.news_url,
- })
- })
- .then((response) => {
- alert('News was shared!');
- this.setState({
- news_title: '',
- news_url: ''
- });
- })
- .catch((err) => {
- alert('Error occured while sharing news');
- });
- }
再往下,openPage函数用于在浏览器中打开新闻项对应的URL:
- openPage(url){
- Linking.canOpenURL(url).then(supported => {
- if(supported){
- Linking.openURL(url);
- }
- });
- }
接下来,RenderNews函数将针对每个新闻项返回UI。这个方法中还负责显示“upvote”按钮、赞成票数和新闻标题。其中,新闻标题被封装在一个TouchableHighlight组件。这允许我们通过执行openPage函数来打开对应的URL。对于赞成票数,也要这样做。
【注意】该代码使用了TouchableHighlight组件而不是Button组件,因为Button组件不能内含View或Text组件。
- renderNews(news){
- return (
- <View style={styles.news_item}>
- <TouchableHighlight onPress={this.upvoteNewsItem.bind(this, news.id, news.upvotes)} underlayColor={"#E8E8E8"}>
- <View style={styles.upvote}>
- <Icon name="triangle-up" size={30} color="#666" />
- <Text style={styles.upvote_text}>{news.upvotes}</Text>
- </View>
- </TouchableHighlight>
- <TouchableHighlight onPress={this.openPage.bind(this, news.url)} underlayColor={"#E8E8E8"}>
- <View style={styles.news_title}>
- <Text style={styles.news_item_text}>{news.title}</Text>
- </View>
- </TouchableHighlight>
- </View>
- );
- }
再往下,render函数负责返回整个应用程序的UI部分:
- render(){
- ...
- }
在render函数中,要建立包含应用程序的标题的标题和一个按钮用于打开模态对话框来分享新闻项的按钮。
- <View style={styles.header}>
- <View style={styles.app_title}>
- <Text style={styles.header_text}>News Sharer</Text>
- </View>
- <View style={styles.header_button_container}>
- <Button onPress={this.openModal.bind(this)} style={styles.btn}>
- Share News
- </Button>
- </View>
- </View>
对于body部分,使用ListView组件来渲染新闻项。它有三个必需的参数:initialListSize,dataSource和renderRow。其中,InitialListSize被设置为1;这样一来,ListView就能够针对内容部分的每一个帧逐行渲染。如果你想一次显示所有行的话,你还可以把这值修改得更大些。dataSource对应于新闻项,renderRow函数用于渲染每一行中的新闻项。
- {
- this.state.is_news_loaded &&
- <View style={styles.body}>
- <ListView initialListSize={1} dataSource={this.state.news} style={styles.news} renderRow={this.renderNews.bind(this)}></ListView>
- </View>
- }
接下来是定义分享新闻的模态对话框。此对话框中使用了两个文本字段分别用于输入标题和新闻URL,还有一个按钮用于将新闻提交到服务器。文本字段使用了TextInput组件实现。由于没有使用标签控件,所以需要在TextInput组件中输入占位符文本来提示用户要输入的内容。
这两个文本字段都有一个onChangeText方法,在文本值更新时使用。keyboardType的Url用于新闻URL的文本字段;这样的话,它将打开设备的键盘,实现输入URL的优化支持。用户不必手动输入内容,可以使用拷贝和粘贴。文本字段的下方是用于共享新闻的按钮。按钮的点击将调用先前定义的shareNews函数。
- <Modal
- isOpen={this.state.is_modal_open}
- style={styles.modal}
- position={"center"}
- >
- <View style={styles.modal_body}>
- <View style={styles.modal_header}>
- <Text style={styles.modal_header_text}>Share News</Text>
- </View>
- <View style={styles.input_row}>
- <TextInput
- style={{height: 40, borderColor: 'gray', borderWidth: 1}}
- onChangeText={(text) => this.setState({news_title: text})}
- value={this.state.news_title}
- placeholder="Title"
- />
- </View>
- <View style={styles.input_row}>
- <TextInput
- style={{height: 40, borderColor: 'gray', borderWidth: 1}}
- onChangeText={(text) => this.setState({news_url: text})}
- value={this.state.news_url}
- placeholder="URL"
- keyboardType="url"
- />
- </View>
- <View style={styles.input_row}>
- <Button onPress={this.shareNews.bind(this)} style={[styles.btn, styles.share_btn]}>
- Share
- </Button>
- </View>
- </View>
- </Modal>
接下来,为组件设置样式:
- const styles = StyleSheet.create({
- container: {
- flex: 1,
- alignSelf: 'stretch',
- backgroundColor: '#F5FCFF',
- },
- header: {
- flex: 1,
- backgroundColor: '#3B3738',
- flexDirection: 'row'
- },
- app_title: {
- flex: 7,
- padding: 10
- },
- header_text: {
- fontSize: 20,
- color: '#FFF',
- fontWeight: 'bold'
- },
- header_button_container: {
- flex: 3
- },
- body: {
- flex: 19
- },
- btn: {
- backgroundColor: "#0***5D1",
- color: "white",
- margin: 10
- },
- modal: {
- height: 300
- },
- modal_header: {
- margin: 20,
- },
- modal_body: {
- alignItems: 'center'
- },
- input_row: {
- padding: 20
- },
- modal_header_text: {
- fontSize: 18,
- fontWeight: 'bold'
- },
- share_btn: {
- width: 100
- },
- news_item: {
- paddingLeft: 10,
- paddingRight: 10,
- paddingTop: 15,
- paddingBottom: 15,
- marginBottom: 5,
- borderBottomWidth: 1,
- borderBottomColor: '#ccc',
- flex: 1,
- flexDirection: 'row'
- },
- news_item_text: {
- color: '#575757',
- fontSize: 18
- },
- upvote: {
- flex: 2,
- paddingRight: 15,
- paddingLeft: 5,
- alignItems: 'center'
- },
- news_title: {
- flex: 18,
- justifyContent: 'center'
- },
- upvote_text: {
- fontSize: 18,
- fontWeight: 'bold'
- }
- });
开发服务器端组件
现在正是时候要移动到的服务器组件的应用程序,在这里,您将学习如何保存和 RethinkDB,upvote 新闻项目以及如何通知应用程序在数据库中发生了变化。
创建数据库
我假定您已经在您的计算机上安装了RethinkDB。否则的话,请按照RethinkDB网站上的提示(https://www.rethinkdb.com/docs/install/)先行安装吧。
安装完毕后,您现在可以打开浏览器访问http://localhost:8080来查看RethinkDB管理控制台。在Tables选项卡上单击,然后单击Add Database按钮。这将打开一个模态窗口,允许您输入数据库的名称,称之为newssharer吧,***单击Click。
现在来创建要在其中保存新闻条目的表。单击Add Table按钮,命名为news_items,然后单击Create Table。
安装依赖性
您可以导航到项目的根目录 (即newssharer-server.js和package.json文件所在位置),执行npm install命令来安装以下服务器依赖项:
1.Express: 基于Node.js的web框架,允许您创建响应特定路由的web服务器。
2.Body-parser:便于从请求正文中提取JSON字符串。
3.Rethinkdb:Node.js的RethinkDB客户端。
4.socket.io:一个实时框架,当有人分享新闻或对现有新闻投赞成票时允许您连接到所有的客户端。
服务端编程
文件newssharer-server.js的代码如下:
- var r = require('rethinkdb');
- var express = require('express');
- var app = express();
- var server = require('http').createServer(app);
- var io = require('socket.io')(server);
- var bodyParser = require('body-parser');
- app.use(bodyParser.json());
- var connection;
- r.connect({host: 'localhost', port: 28015}, function(err, conn) {
- if(err) throw err;
- connection = conn;
- r.db('newssharer').table('news_items')
- .orderBy({index: r.desc('upvotes')})
- .changes()
- .run(connection, function(err, cursor){
- if (err) throw err;
- io.sockets.on('connection', function(socket){
- cursor.each(function(err, row){
- if(err) throw err;
- io.sockets.emit('news_updated', row);
- });
- });
- });
- });
- app.get('/create-table', function(req, res){
- r.db('newssharer').table('news_items').indexCreate('upvotes').run(connection, function(err, result){
- console.log('boom');
- res.send('ok')
- });
- });
- app.get('/fill', function(req, res){
- r.db('newssharer').table('news_items').insert([
- {
- title: 'A Conversation About Fantasy User Interfaces',
- url: 'https://www.subtraction.com/2016/06/02/a-conversation-about-fantasy-user-interfaces/',
- upvotes: 30
- },
- {
- title: 'Apple Cloud Services Outage',
- url: 'https://www.apple.com/support/systemstatus/',
- upvotes: 20
- }
- ]).run(connection, function(err, result){
- if (err) throw err;
- res.send('news_items table was filled!');
- });
- });
- app.get('/news', function(req, res){
- res.header("Content-Type", "application/json");
- r.db('newssharer').table('news_items')
- .orderBy({index: r.desc('upvotes')})
- .limit(30)
- .run(connection, function(err, cursor) {
- if (err) throw err;
- cursor.toArray(function(err, result) {
- if (err) throw err;
- res.send(result);
- });
- });
- });
- app.post('/save-newsitem', function(req, res){
- var news_title = req.body.news_title;
- var news_url = req.body.news_url;
- r.db('newssharer').table('news_items').insert([
- {
- 'title': news_title,
- 'url': news_url,
- 'upvotes': 100
- },
- ]).run(connection, function(err, result){
- if (err) throw err;
- res.send('ok');
- });
- });
- app.post('/upvote-newsitem', function(req, res){
- var id = req.body.news_id;
- var upvote_count = req.body.upvotes;
- r.db('newssharer').table('news_items')
- .filter(r.row('id').eq(id))
- .update({upvotes: upvote_count})
- .run(connection, function(err, result) {
- if (err) throw err;
- res.send('ok');
- });
- });
- app.get('/test/upvote', function(req, res){
- var id = '144f7d7d-d580-42b3-8704-8372e9b2a17c';
- var upvote_count = 350;
- r.db('newssharer').table('news_items')
- .filter(r.row('id').eq(id))
- .update({upvotes: upvote_count})
- .run(connection, function(err, result) {
- if (err) throw err;
- res.send('ok');
- });
- });
- app.get('/test/save-newsitem', function(req, res){
- r.db('newssharer').table('news_items').insert([
- {
- 'title': 'banana',
- 'url': 'http://banana.com',
- 'upvotes': 190,
- 'downvotes': 0
- },
- ]).run(connection, function(err, result){
- if(err) throw err;
- res.send('ok');
- });
- });
- server.listen(3000);
在上面的代码中,您首先导入依赖项:
- var r = require('rethinkdb');
- var express = require('express');
- var app = express();
- var server = require('http').createServer(app);
- var io = require('socket.io')(server);
- var bodyParser = require('body-parser');
- app.use(bodyParser.json());
然后,创建用于存储当前的RethinkDB连接的变量。
- var connection;
监听变化
连接到RethinkDB数据库,默认情况下在端口28015(即创建连接的地址处)上运行RethinkDB。如果你想使用不同的端口,可以将28015替换为你所使用的端口。
- r.connect({host: 'localhost', port: 28015}, function(err, conn) {
- if(err) throw err;
- connection = conn;
- ...
- });
还是在数据库连接代码中,查询newssharer数据库中的表news_items,并按投票计数排序项目。然后,使用RethinkDB的Changefeeds功能来侦听表(数据库排序日志)中的更改。表中发生每一次变化(CRUD操作),都会发出此通知。
- r.db('newssharer').table('news_items')
- .orderBy({index: r.desc('upvotes')})
- .changes()
- .run(connection, function(err, cursor){
- ...
- });
在run方法里面的回调函数中,初始化套接字连接并循环遍历cursor的内容。Cursor描述了表中所做的更改。每次发生更改时,都会触发cursor.each函数。
【注意】该函数并不包含所有的数据更改。每当有新的更改时,获取替换以前的更改。这意味着,在任何给定时间内只能遍历单行。这将允许您使用socket.io来把更改发送到客户端。
- if (err) throw err; //check if there are errors and return it if any
- io.sockets.on('connection', function(socket){
- cursor.each(function(err, row){
- if(err) throw err;
- io.sockets.emit('news_updated', row);
- });
- });
如果有新闻项被共享,那么每一行都有下列结构:
- {
- "old_val": null,
- "new_val": {
- "id": 1,
- "news_title": "Google",
- "news_url": "http://google.com",
- "upvotes": 0
- }
- }
这就是为什么前面代码中检查null,因为一个新建的新闻项不会有一个old_val。
如果用户对一个新闻项投赞成票:
- {
- "old_val": {
- "id": 1,
- "news_title": "Google",
- "news_url": "http://google.com",
- "upvotes": 0
- }
- "new_val": {
- "id": 1,
- "news_title": "Google",
- "news_url": "http://google.com",
- "upvotes": 1
- }
- }
那么,将返回该行中的对应于旧值和新值的完整结构。这意味着,你可以在一个客户端更新多个字段,并且可以把所有这些变化发送给其他相连接的客户端。借助于RethinkDB的changfeeds特性,基于RethinkDB开发实时应用变得特别简单。
对Upvotes字段添加索引
下面这个路由把一个索引添加到upvotes字段上:
- app.get('/add-index', function(req, res){
- r.db('newssharer').table('news_items').indexCreate('upvotes').run(connection, function(err, result){
- res.send('ok')
- });
- });
上述创建索引操作对于orderBy功能来说是必需的,因为它需要你排序的字段有一个索引。
- .orderBy({index: r.desc('upvotes')})
当服务器运行时,在测试应用前请确保在你的浏览器中打开网址http://localhost:3000/add-index。注意,上面这个路由仅需要调用一次。
添加空新闻项
下面这个路由将在news_items表中插入一个空的入口。实际上这是用于测试目的的一个可选功能;这样一来,在不需要使用程序添加的情况下你会立即看到表中出现一个新项。
- app.get('/fill', function(req, res){
- r.db('newssharer').table('news_items').insert([
- {
- title: 'A Conversation About Fantasy User Interfaces',
- url: 'https://www.subtraction.com/2016/06/02/a-conversation-about-fantasy-user-interfaces/',
- upvotes: 30
- },
- {
- title: 'Apple Cloud Services Outage',
- url: 'https://www.apple.com/support/systemstatus/',
- upvotes: 20
- }
- ]).run(connection, function(err, result){
- if (err) throw err;
- res.send('news_items table was filled!');
- });
- });
返回新闻项
下面的路由将返回新闻项:
- app.get('/news', function(req, res){
- res.header("Content-Type", "application/json");
- r.db('newssharer').table('news_items')
- .orderBy({index: r.desc('upvotes')})
- .limit(30)
- .run(connection, function(err, cursor) {
- if (err) throw err;
- cursor.toArray(function(err, result) {
- if (err) throw err;
- res.send(result);
- });
- });
- });
注意到,该新闻项按照赞成票数从高到低的顺序排序,并且限定为最多30条。另外,这里没有使用cursor.each来遍历新闻项,而是使用cursor.toArray并通过如下结构把新闻项转换成一个数组:
- [
- {
- "title": "A Conversation About Fantasy User Interfaces",
- "url": "https://www.subtraction.com/2016/06/02/a-conversation-about-fantasy-user-interfaces/",
- "upvotes": 30
- },
- {
- "title": "Apple Cloud Services Outage",
- "url": "https://www.apple.com/support/systemstatus/",
- "upvotes": 20
- }
- ]
新建与保存新闻项
下面的路由实现新建与保存新闻项功能:
- app.post('/save-newsitem', function(req, res){
- var news_title = req.body.news_title;
- var news_url = req.body.news_url;
- r.db('newssharer').table('news_items').insert([
- {
- 'title': news_title,
- 'url': news_url,
- 'upvotes': 100
- },
- ]).run(connection, function(err, result){
- if (err) throw err;
- res.send('ok');
- });
- });
当一个用户共享应用程序中的新闻项时将调用上面的路由。它接收来自于请求正文(Request Body)的新闻标题和URL数据。最初的赞成票数设置为100,但您可以选择另一个数字。
对新闻项投赞成票
下面的路由实现对新闻项投赞成票功能:
- app.post('/upvote-newsitem', function(req, res){
- var id = req.body.news_id;
- var upvote_count = req.body.upvotes;
- r.db('newssharer').table('news_items')
- .filter(r.row('id').eq(id))
- .update({upvotes: upvote_count})
- .run(connection, function(err, result) {
- if (err) throw err;
- res.send('ok');
- });
- });
当用户在程序中对新闻项投赞成票时将调用上面的路由函数。该函数使用新闻项的ID来取回新闻数据并更新之。
【注意】你已经在应用程序中把upvotes值加1了;因此,也就已经提供了请求主体(Request Body)内的数据。
测试保存与投赞成票功能
至此,我们已经建立了好几个路由。现在,不妨来测试一下保存功能与投赞成票功能。实现这一测试的***时机是当应用程序已经运行于移动设备上时。如此一来,你便能够看到UI更新情况。我们将在下一节讨论如何运行程序的问题。
下面的路由实现测试保存功能:
- app.get('/test/save-newsitem', function(req, res){
- r.db('newssharer').table('news_items').insert([
- {
- 'title': 'banana',
- 'url': 'http://banana.com',
- 'upvotes': 190,
- 'downvotes': 0
- },
- ]).run(connection, function(err, result){
- if(err) throw err;
- res.send('ok');
- });
- });
下面的路由实现对新闻项投赞成票的测试功能。记住一定要使用已有新闻项的ID来取代程序中的ID才能使测试正常进行:
- app.get('/test/upvote', function(req, res){
- var id = '144f7d7d-d580-42b3-8704-8372e9b2a17c';
- var upvote_count = 350;
- r.db('newssharer').table('news_items')
- .filter(r.row('id').eq(id))
- .update({upvotes: upvote_count})
- .run(connection, function(err, result) {
- if (err) throw err;
- res.send('ok');
- });
- });
运行服务器端程序
到此,我假定RethinkDB一直运行于后台中。如果还没有运行起来,请先运行它。一旦RethinkDB运行后,你就可以在项目根目录下执行命令node newssharer-server.js来运行程序的服务器端组件了。
运行移动客户端程序
你可以使用与任何React Native程序一样的方式来启动你自己的应用程序。下面提供了在不同平台运行程序的链接,供参考:
1. 运行于Android平台的参考地址是:https://facebook.github.io/react-native/docs/running-on-device-android.html#content
2. 运行于iOS平台的参考地址是:https://facebook.github.io/react-native/docs/running-on-device-ios.html#content
一旦应用程序正常运行,请试着在浏览器中访问所有的测试例程。
小结
本文中提供的示例仅仅实际了最基本的部分,你还可以通过下面几个方面进一步改进这个程序:
1.不是通过移动设备的浏览器打开新闻内容,而是使用React Native的WebView组件在应用程序内创建一个webview的方式进行。
2.当前的程序允许用户不停地点击“upvote”按钮,你不妨添加一个功能来检查是否当前用户已经对当前新闻项投了赞成票。
3.修改服务器端程序,使之仅接收来自我们自己的应用程序发来的请求。
总而言之,通过本文的学习,你应当能够使用Socket.io和RethinkDB的changefeeds功能来创建一个基本型实时的新闻分享程序。