它使用 Slim PHP 微型框架与 Nutritionix API 通信来执行信息检索,使用 jQuery Mobile 来建立用户界面,还使用了 PHP CloudFoundry 构建块和 SendGrid 服务实例。IBM® Bluemix 是一款 beta 级产品,随着我们不断让其功能更加完善和更易于使用,它也将不断改进。我们会竭尽全力保持本文最新,但并不总是完全跟得上现状。感谢大家的理解!
直到几年前,要计算您刚吃的三明治中含有多少卡路里,还需要依靠猜测和查看包装。如今,同样的信息可从许多在线营养数据库中获取,这使得跟踪食物摄入量变得更容易。
本文将介绍如何创建一个在线卡路里计数器,它使得用户能够:
按名称搜索食物,通过一个 API 从在线营养数据库 Nutritionix 中检索结果。 使用一个 PHP/AngularJS 应用程序,将选定的食物分组在一起来创建用餐记录,并将这些记录与它们的卡路里计数一起保存在一个 MySQL 数据库中。 检索他们今天、最近七天和最近 30 天消耗的卡路里总量。 从平板电脑和智能电话等移动设备访问该应用程序。
在客户端,我使用 jQuery Mobile 为该应用程序创建了一个移动友好的用户界面,并使用 AngularJS 来启用该应用程序的一些交互式特性。在服务器上,我使用 Slim(一个 PHP 微型框架)来控制与 Nutritionix API 的交互,并在 MySQL 服务器中保存和检索数据。
最后一部分将会介绍如何将应用程序部署到 Bluemix 云,该云提供了一种用于应用程序部署的、可扩展、健全的基础架构,可确保用户拥有全天候的访问能力。
听起来是否很有趣?让我们开始吧!
完成该应用程序的先决条件
基本熟悉 jQuery Mobile、AngularJS、PHP、MySQL,以及 Apache 或 nginx 一个具有外寄邮件服务器(outgoing mail server)的本地 PHP/MySQL 开发环境 一个 Bluemix 帐户 一个 Nutritionix API 帐户 Composer(PHP 依赖项管理器) CloudFoundry 命令行工具 一个文本编辑器或 IDE
第 1 步. 设置应用程序数据库
使用下面这个代码清单(包含一个 MySQL 表定义和示例数据)来设置应用程序数据库。
如果仅在本地进行开发和部署,那么可以使用此代码初始化一个 MySQL 数据库表,让应用程序连接到该表。 如果在 Bluemix 上进行部署,那么可以暂时跳过此步骤;在初始化 Bluemix 上的一个 MySQL 服务实例并绑定它之后,我会在 第 8 步 介绍如何部署它。CREATE TABLE meals ( id int(11) NOT NULL AUTO_INCREMENT, uid varchar(255) NOT NULL, calories decimal(10,2) NOT NULL, rdate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, ip varchar(20) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8CREATE TABLE users ( id int(11) NOT NULL AUTO_INCREMENT, email varchar(255) NOT NULL, `password` varchar(255) NOT NULL, code varchar(255) DEFAULT NULL, `status` int(11) NOT NULL, rdate timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, ip varchar(20) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8;
第 2 步. 安装 Slim
下载并设置 Slim 微型框架。为什么选择 Slim?Slim 包含一个复杂的 URL 路由器,而且支持 Flash 消息、加密 cookie 和中间件。它也容易理解和使用,而且具有优秀的文档。
我使用 Composer(PHP 依赖项管理器)下载和设置 Slim。除了 Slim 之外,我还添加了针对 PHP 的 SendGrid 客户端库。(进一步了解 SendGrid 和为什么需要它。)下面这个代码清单是 Composer 配置文件。将此文件保存到 $APP_ROOT/composer.json(其中 $APP_ROOT 指您的工作目录)。
{ "require": { "slim/slim": "2.*", "sendgrid/sendgrid": "2.0.5" }}
现在可以使用 Composer 和以下命令安装 Slim:
shell> php composer.phar install
要使该应用程序更容易访问,还可以在开发环境中定义一个新虚拟主机,并将它的文档根指向 $APP_ROOT。推荐执行这一步(但是可选的),因为这会为 Bluemix 上的目标部署环境创建一个很接近的副本。
要在 Apache 中为该应用程序设置一个命名虚拟主机,可以打开 Apache 配置文件(httpd.conf 或 httpd-vhosts.conf)并添加以下代码:
NameVirtualHost 127.0.0.1<VirtualHost 127.0.0.1> DocumentRoot "/var/www/calories" ServerName calories.localhost</VirtualHost>
要在 nginx 中为该应用程序设置一个命名虚拟主机,可以打开 nginx 配置文件 (nginx.conf) 并添加以下代码:
server { server_name calories.localhost; root /var/www/calories; try_files $uri /index.php; location ~ \.php$ { try_files $uri =404; include fastcgi_params;fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; # assumes you are using php-fcgi fastcgi_pass 127.0.0.1:90; } }
这些代码定义了一个新的虚拟主机 http://calories.localhost/,该主机的文档根对应于 $APP_ROOT(请记得更新它来反映您自己的本地设置)。重新启动 Web 服务器,以便激活这些新设置。您可能需要更新网络的本地 DNS 服务器来指示使用这个新主机。
第 3 步. 了解 Nutritionix API
与其他许多 Web API 一样,Nutritionix API 通过 HTTP 进行工作,要求将 HTTP 请求发送到指定的端点。收到这个请求后,API 服务器会使用包含一个请求的数据的 JSON 源来回复查询。然后,它可以使用一种服务器端编程语言(比如 PHP 或 Perl)或一个客户端工具包(比如 jQuery 或 AngularJS)来解析此数据,并从中提取内容,以便将它们集成到一个网页中。
注册一个 Nutritionix API 帐户并获得有效的 appId 和 appKey 后,可以使用该 API 作为测试平台,搜索与词汇 “chicken” 匹配的食物。这个免费的开发人员帐户仅支持每天搜索 500 次(但您可以向 API 团队发送电子邮件来请求提高这一限制)。
看看下一幅图,其中显示了对一个针对 https://api.nutritionix.com/v1_1/search/chicken?fields=item_name,brand_name,nf_calories&item_type=3&appId=[APP-ID]&appKey=[APP-KEY](用于搜索查询的 API 端点)的经过验证的 GET 请求的响应(在发出请求之前,请记住更新以前的 URL,以反映您的 API 凭据)。
如该图所示,Nutritionix API 使用一个 JSON 文档响应该请求,该文档列出了与搜索词汇 “chicken” 匹配的食物。查询字符串包含 item_type=3参数,该参数将搜索范围限制到 USDA 数据库。对于每种食物,响应包含食物名称、品牌名称和卡路里计数。也支持其他字段;请查阅Nutritionix API 文档 了解有关的详细信息。
第 4 步. 启用搜索界面
开发一个简单的搜索界面,让用户能够搜索食物并查看结果列表。结果页必须包含一些控件,用户可以使用这些控件将所选的食物添加到其用餐记录中。
奢侈此用户界面的基本结构并将其保存为 $APP_ROOT/templates/main.php。
<!DOCTYPE html><html><head> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="http://code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.min.css" /> <script src="http://code.jquery.com/jquery-1.9.1.min.js"></script> <script src="http://code.jquery.com/mobile/1.4.2/jquery.mobile-1.4.2.min.js"></script> <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.0-beta.6/angular.min.js"></script></head><body> <div data-role="page"> <div data-role="header"> <h1>Calorie Counter</h1> </div> <div data-role="content" ng-app="myApp"> <div data-role="tabs" ng-controller="myAppController"> <div data-role="navbar"> <ul> <li><a href="#search" data-theme="a" class="ui-btn-active">Search</a></li> <li><a href="#record" data-theme="a">Record</a></li> <li><a href="#report" data-theme="a">Report</a></li> </ul> </div> <div id="search"></div> <div id="record"></div> <div id="report"></div> </div> </div> </div></body></html>
前面的代码清单显示了一个符合标准 jQuery Mobile 约定的格式的页面。主要页面元素是一个 <div> 元素,它有一个 data-role="page" 属性。这个 <div> 元素包含针对页眉和内容的不同 <div> 元素。页面内容包含一系列选项卡。每个选项卡表示一个任务(“search”、“record” 和 “report”)。单击顶部导航栏中的选项卡名称将会显示它的内容。
接下来,向 search 选项卡添加元素,如下面这个代码清单所示:
<!DOCTYPE html>... <div data-role="content" ng-app="myApp"> <div data-role="tabs" ng-controller="myAppController"> <div id="search"> <h2 class="ui-bar ui-bar-a">Food Item Search</h2> <div class="ui-body"> <input type="search" name="query" ng-model="foodItems.query" /> <button ng-click="search()">Search</button> </div> <h2 class="ui-bar ui-bar-a">Search Results</h2> <div class="ui-body"> <ul data-role="listview" data-split-theme="d"> <li ng-repeat="r in foodItems.results"> <a>{{r.fields.item_name}} / {{r.fields.nf_calories + ' calories'}}</a><a href="#" data-inline="true" data-role="button" data-icon="plus" data-theme="a" ng-click="addToMeal(r)">Add</a> </li> </ul> </div> </div> </div> </div>...
search 选项卡现在包含两个区域:顶部的搜索输入字段和底部的搜索结果列表。两个区域由一个 AngularJS 控制器控制,二者都使用了一个名为 foodItems 的 AngularJS 模型。下面这个代码清单显示了该控制器的代码。
<script> var myApp = angular.module('myApp', []); function myAppController($scope, $http) { // related to search functionality $scope.mealItems = []; $scope.foodItems = {}; $scope.foodItems.results = []; $scope.foodItems.query = ''; $scope.search = function() { if ($scope.foodItems.query != '') { $http({ method: 'GET', url: '/search/' + $scope.foodItems.query, }). success(function(data) { $scope.foodItems.results = data.hits; }); }; }; $scope.addToMeal = function(foodItem) { $scope.mealItems.push(foodItem); }; } </script>
这是该搜索界面的实际外观。
它的工作原理是什么?用户输入一个搜索词汇并单击 Search 时,AngularJS search() 函数会通过 foodItems 模型检索输入,并向 /search 应用程序端点生成一个 Ajax 请求。此请求不是 Nutritionix API 端点,而是一个由应用程序本身管理的中间 API 端点(稍后将会详细介绍)。
Ajax 请求的响应是一个 JSON 包,与之前显示的内容类似。这段响被附加到 foodItems.results 属性中,AngularJS 数据绑定负责迭代此集合,解析它,并将它显示为一个搜索结果列表。
请仔细看看这个搜索界面。请注意,每个搜索结果旁边都有一个按钮,该按钮链接到 addToMeal() 函数。单击此按钮时,相应的食物会添加到该范围内的一个 mealItems 数组中。稍后,系统会使用这个数组构建 record 选项卡的视图。
在服务器端,需要一个处理函数来处理对 /search 端点的 Ajax 请求,这时就需要使用 Slim。Slim 使用 Ajax 请求数据连接到 Nutritionix API 并运行一次搜索,类似于之前显示的搜索。下面这个代码清单给出了实现此功能的代码。
<?php// use Composer autoloaderrequire 'vendor/autoload.php';\Slim\Slim::registerAutoloader();// configure Slim application instance$app = new \Slim\Slim();$app->config(array( 'debug' => true, 'templates.path' => './templates'));// configure credentials// ... for Nutritionix$config["nutritionix"]["appId"] = 'APP-ID';$config["nutritionix"]["appKey"] = 'APP-KEY';// index page handlers$app->get('/', function () use ($app) { $app->redirect('/index');});$app->get('/index', function () use ($app) { $app->render('main.php');});// search handler$app->get('/search/:query', function ($query) use ($app, $config) { try { // execute search on Nutritionix API // specify search scope and required response fields // replace with your API credentials$qs = http_build_query(array('appId' => $config["nutritionix"]["appId"], 'appKey' => $config["nutritionix"]["appKey"], 'item_type' => '3', 'fields' => 'item_name,brand_name,nf_calories'));$url = 'https://api.nutritionix.com/v1_1/search/' . str_replace(' ', '+', $query) . '?' . $qs; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_exec($ch); curl_close($ch); } catch (Exception $e) { $app->response()->status(400); $app->response()->header('X-Status-Reason', $e->getMessage()); }});$app->run();
此脚本(需要保存为 $APP_ROOT/index.php)首先加载 Slim 库,初始化并配置一个新的 Slim 应用程序对象。具体来讲,Slim 应用程序对象必须配置 jQuery Mobile 页面模板的路径,以便能够根据需要呈现这些模板。
Slim 定义了对 HTTP 方法和端点的路由器回调。为了执行此操作,它调用了相应的方法(例如 get() 来处理 GET 请求,或者调用 post() 来处理 POST 请求),并将 URL 路由,使之与该方法的第一个参数匹配。该方法的第二个参数是一个函数,它指定了在路由与一个传入的请求匹配时应采取的操作。前面的代码清单设置了两个这样的路由器回调:/index 和 /search。
/index 徽标:呈现主要应用程序页面模板。此回调包含各种选项卡、jQuery Mobile 页面元素和 AngularJS 控制器代码。 /search 回调:处理 AngularJS 控制器发送的 Ajax 搜索请求。它接受一个搜索词汇,然后使用 PHP http_build_query() 方法构造一个对 Nutritionix API 的请求的 URL。该请求通过 cURL 发送到该 API,响应作为一个 JSON 文档返回给应用程序前端。然后,AngularJS 负责解析响应数据并将它们绑定到该范围。
也可以直接从应用程序前端使用 AngularJS 运行 Ajax 请求。但是,执行此操作会向用户暴露您的私有 Nutritionix API 应用程序密钥,对于可公开访问的应用程序,不推荐这样做。使用一段服务器端脚本来执行请求会增加一些开销,但具有更高的安全性。
第 5 步. 计算和存储用餐记录
完成搜索界面后,下一步是构建用户界面中的第二个选项卡。此选项卡显示了用户选定的食物的列表,以及这些食物中的卡路里总量。它包含一些将用餐记录保存到数据库中的控件。使用以下代码。
<!DOCTYPE html>…<head> <script> var myApp = angular.module('myApp', []); function myAppController($scope, $http) { // related to record functionality $scope.removeFromMeal = function(index) { $scope.mealItems.splice(index, 1); }; $scope.clearMeal = function() { $scope.mealItems.length = 0; }; $scope.getTotalCalories = function() { var sum = 0; for(i=0; i<$scope.mealItems.length; i++) { sum += $scope.mealItems[i].fields.nf_calories; } return sum.toFixed(2); }; $scope.record = function() { if ($scope.getTotalCalories() > 0) { $http({ method: 'POST', url: '/record', data: {'totalCalories': $scope.getTotalCalories()} }). success(function(data) { $scope.clearMeal(); }); }; }; } </script></head><body>... <div data-role="content" ng-app="myApp"> <div data-role="tabs" ng-controller="myAppController"> <div data-role="navbar"> <ul> <li><a href="#search" data-theme="a" class="ui-btn-active">Search</a></li> <li><a href="#record" data-theme="a">Record <span class="ui-li-count"> {{ getTotalCalories() }} / {{ mealItems.length }}</span></a></li> <li><a href="#report" data-theme="a">Report</a></li> </ul> </div> <div id="record"> <h2 class="ui-bar ui-bar-a">Meal Record</h2> <div class="ui-body"> <ul data-role="listview" data-split-theme="d"> <li ng-repeat="item in mealItems track by $index"> <a>{{item.fields.item_name}} / {{item.fields.nf_calories + ' calories'}}</a><a href="#" data-inline="true" data-role="button" data-icon="minus" data-theme="a" ng-click="removeFromMeal($index)">Add</a> </li> </ul> </div> <div class="ui-body"> <button ng-click="record()">Save</button> </div> </div> </div> </div>…</body></html>
下一个屏幕截图显示了该界面的实际外观。
通过迭代 第 4 步 中的 mealItems 数组来生成选定食物列表非常容易。在用户选择搜索界面中的新食物时,AngularJS 数据绑定机制可确保该列表会即时更新。
完成用餐记录后,用户单击 Save 将该记录保存到数据库中。record() 函数创建了一个对 /record 端点的 Ajax POST 请求,将它传递给用餐记录的卡路里总量中。如果该 Ajax 请求被成功处理,那么 mealItems 数组将会被清除,准备存储下一次用餐的记录。
在上一个清单中,请注意另外两点:
导航栏包含两个计数器:所选食物的卡路里总量和食物总数。当用户在用餐记录中添加和删除食物时,该计数会自动更新。此更新同样是使用数据绑定来完成的。这两个值可在 mealItems 数组长度和 getTotalCalories() 控制器方法中进行动态更新。 用户可单击每个食物旁边的按钮,从用餐记录中删除选定的食物。此操作会调用 removeFromMeal() 控制器方法,该方法使用所选食物的索引来从 mealItems 数组中删除它。数据绑定负责更新食物和导航栏计数器。
在服务器端,需要添加一个对 /record 端点的 Slim 回调。您可能已经猜到,此回调将会读取应用程序前端发送的卡路里总量,并将它持久保存在 第 1 步 中创建的 MySQL 数据库中。该回调的代码为:
<?php// use Composer autoloaderrequire 'vendor/autoload.php';\Slim\Slim::registerAutoloader();// configure credentials// ... for Nutritionix$config["nutritionix"]["appId"] = 'APP-ID';$config["nutritionix"]["appKey"] = 'APP-KEY';// ... for MySQL$config["db"]["name"] = 'test';$config["db"]["host"] = 'localhost';$config["db"]["port"] = '3306';$config["db"]["user"] = 'root';$config["db"]["password"] = 'guessme';// if Bluemix VCAP_SERVICES environment available// overwrite with credentials from Bluemixif ($services = getenv("VCAP_SERVICES")) { $services_json = json_decode($services, true); $config["db"] = $services_json["mysql-5.5"][0]["credentials"];}// configure Slim application instance$app = new \Slim\Slim();$app->config(array( 'debug' => true, 'templates.path' => './templates'));// initialize PDO object$db = $config["db"]["name"];$host = $config["db"]["host"];$port = $config["db"]["port"];$username = $config["db"]["user"];$password = $config["db"]["password"]; $dbh = new PDO("mysql:host=$host;dbname=$db;port=$port;charset=utf8", $username, $password);// start sessionsession_start();// record handler$app->post('/record', function () use ($app, $dbh) { try { // get and decode JSON request body $request = $app->request(); $body = $request->getBody(); $input = json_decode($body); // insert meal record $stmt = $dbh->prepare('INSERT INTO meals (uid, calories, rdate, ip) VALUES(?, ?, ?, ?)');$stmt->execute(array($_SESSION['uid'], $input->totalCalories, date('Y-m-d h:i:s', time()), $_SERVER['SERVER_ADDR'])); $input->id = $dbh->lastInsertId(); // return JSON-encoded response body $app->response()->header('Content-Type', 'application/json'); echo json_encode($input); } catch (Exception $e) { $app->response()->status(400); $app->response()->header('X-Status-Reason', $e->getMessage()); } });// snip: other handlers$app->run();
此过程首先配置本地应用程序数据库的凭据。然后在 PHP 环境中检查特殊的 VCAP_SERVICES 环境变量。在 Bluemix 上,此变量拥有绑定的服务实例的凭据。如果找到此变量,该脚本会假设它在 Bluemix 上运行,使用这些凭据来初始化一个与绑定的 MySQL 实例的 PDO 连接。如果未找到此变量,该脚本会假设它在一个本地开发实例上运行,并使用本地数据库的凭据。
接下来,该清单为 /record 路由定义了一个 POST 回调处理函数。此处理函数收到包含卡路里总量的 Ajax POST 请求,并创建 SQL INSERT 语句来将这些计数保存到数据库中。除了卡路里计数之外,该处理函数还会在语句中自动添加时间戳、客户端的 IP 地址和登录用户的惟一标识符。如果 INSERT 成功完成,该处理函数会向请求的 Ajax 脚本返回一个包含记录标识符的 JSON 包。
您可能想知道用户标识符来自何处。这将在 第 7 步 中详细介绍,但简单地讲,每个应用程序用户都有一个在注册时生成的惟一标识符。用户登录时,此标识符会被添加到 $_SESSION['uid'] 变量中的会话中,并在您保存和检索特定于用户的信息时插入到各种 SQL 语句中。