지난번 글 (2010/07/27 - [Cloud Development] - Hello Windows Azure / Twitter 스타일 방명록 만들기 #1)에 이어서 오늘 시간에는 ASP.NET MVC 2를 사용하는 Web Role 위에서 jQuery, jTemplate을 이용하여 기본적인 방명록 UI를 꾸며보고, 별 다른 Worker Role의 구현 없이 Windows Azure Table Storage를 경유하여 방명록의 글을 삽입, 삭제, 변경하는 기능을 구현해보기로 하겠습니다.
시작하기 전에 (2010.08.09 Update)
지난번 코드에서 누락되거나 교정될 필요가 있는 코드를 포함하여 업데이트를 할 부분이 있어 말씀을 전합니다. TwistDataSource.cs 파일의 내용을 다음과 같이 작성해야 하며, 지난번 코드에서 변경된 부분을 밑줄로 표시해두었습니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.WindowsAzure;
using System.Data.Services.Client;
using Microsoft.WindowsAzure.StorageClient;
namespace TwistBook.DataModel
{
public class TwistDataSource
{
private static CloudStorageAccount storageAccount;
private TwistDataServiceContext serviceContext;
static TwistDataSource()
{
// 중요: 실제로 응용프로그램을 Cloud 환경에 배포할 때에는
// Cloud Project 내의 다른 환경 설정 문자열을 이용하도록
// 호출을 변경해야 합니다.
storageAccount = CloudStorageAccount.DevelopmentStorageAccount;
CloudTableClient.CreateTablesFromModel(
typeof(TwistDataServiceContext),
storageAccount.TableEndpoint.AbsoluteUri,
storageAccount.Credentials);
}
public TwistDataSource()
{
this.serviceContext = new TwistDataServiceContext(storageAccount);
this.serviceContext.RetryPolicy = RetryPolicies.Retry(
3, TimeSpan.FromSeconds(1));
}
public DataServiceResponse Insert(TwistModel model)
{
this.serviceContext.AddObject(
TwistDataServiceContext.TwistModelName,
model);
return this.serviceContext.SaveChangesWithRetries();
}
public IEnumerable<TwistModel> Select()
{
var results = from eachTwist in this.serviceContext.TwistModel
select eachTwist;
var query = new CloudTableQuery<TwistModel>(
results as DataServiceQuery<TwistModel>,
RetryPolicies.Retry(3, TimeSpan.FromSeconds(1)));
return query.Execute();
}
public DataServiceResponse Delete(TwistModel model)
{
// 이 부분의 코드가 삭제되었습니다.
this.serviceContext.DeleteObject(model);
return this.serviceContext.SaveChanges();
}
public DataServiceResponse Update(TwistModel model)
{
this.serviceContext.UpdateObject(model);
return this.serviceContext.SaveChanges();
}
}
}
Web Role 완성하기
1. ASP.NET MVC 2 응용프로그램의 특성을 잘 살리기 위하여 AJAX 기술을 활용하는 방식으로 예제를 설명하고자 합니다. 이를 위하여 필요한 것이 jQuery와 jTemplate 라이브러리인데, jQuery의 경우 ASP.NET MVC 2 프로젝트를 만들면 자동으로 아래의 Scripts 디렉터리에 1.4 버전이 번들링되어있으니 별도로 받으실 필요가 없습니다.
자바스크립트 라이브러리들의 경우, 근래 들어서는 4GL 개발 도구들의 영향으로 Debug Version과 Release Version 라이브러리를 각기 개별적으로 제공하는 경우가 늘었습니다. jQuery도 이러한 추세를 잘 따르고 있으며, 위의 화면에서 jquery-1.4.1-vsdoc.js 파일은 Debug 목적 + Visual Web Developer용 Intellisense 지원을 위한 버전이고, jquery-1.4.1.js 파일은 원래의 소스 코드가 있는 그대로 (as-is) 제공되는 버전입니다. 그리고 jquery-1.4.1.min.js 파일은 원래의 소스 코드에서 주석과 공백 제거, 변수명 최소화와 같은 Obfuscation Process를 포함한 Minified Process를 거친 전송에 최적화된 버전입니다.
자바스크립트 전송에 필요한 대역폭을 좀 더 아낄 필요가 있고, 접속하는 브라우저들이 모두 G-ZIP 압축 해제 기능을 지원한다는 점을 확신할 수 있다면, WSFU (Windows Service For Unix)나 Cygwin, GNU for Win32 등을 통해서 액세스할 수 있는 GZIP 압축 유틸리티를 이용하여 Minified Version을 GZIP 파일로 한 번 더 묶어서 이를 다운로드하도록 구성하는 것도 좋은 선택이 될 수 있습니다. WSFU는 http://www.microsoft.com/downloads/details.aspx?FamilyID=896c9688-601b-44f1-81a4-02878ff11778&DisplayLang=en 에서 다운로드 가능합니다.
2. jTemplate은 jQuery를 기반으로 만들어진 플러그인으로 HTML이나 XML 컨텐츠를 지정된 지시자에 맞추어 반복 생성하거나, 내용을 치환하거나, 수식을 계산하는 등의 복잡한 연산 작업을 가능하게 합니다. 특히 JSON (Java Script Object Notation) 기반의 데이터를 내려보내어줄 것이므로 이러한 기능은 필수적입니다. jTemplate은 http://plugins.jquery.com/project/jTemplates 에서 다운로드받으실 수 있고, 압축 파일을 다운로드받으면 아래와 유사한 형태로 나타납니다.
3. jquery-jtemplates.js 파일을 선택하여 ASP.NET MVC 2 프로젝트의 Scripts 디렉터리 아래로 복사합니다. jQuery 라이브러리와 같은 위치에 배치하여 불러오기 쉽도록 만들기 위한 선택입니다.
4. Visual Studio 솔루션 탐색기에서 방금 압축 해제한 jTemplate 라이브러리의 소스 코드를 추가해야 합니다. 솔루션 탐색기에서 Web Role 프로젝트 아래의 Scripts 디렉터리를 아래 그림과 같이 클릭하고 상단 도구 모음의 "모든 파일 표시" 버튼을 클릭하면 아직 등록되지 않은 jTemplate 라이브러리의 파일이 나타납니다.
5. jquery.jtemplates.js 파일을 오른쪽 버튼으로 클릭하고 "프로젝트에 포함" 메뉴를 클릭하면 솔루션의 일부로 편입됩니다. 이 때, jquery.jtemplates.js 파일을 오른쪽 버튼으로 클릭하고 속성 메뉴를 선택하여 나타나는 속성 창에서 빌드 작업이 "내용"으로 선택되어있는지 반드시 확인하여 주세요. "내용"으로 선택되어있지 않은 파일은 실제 배포 때 제외될 수도 있습니다.
6. 이제 마스터 페이지에 jQuery와 jTemplate 라이브러리를 추가해야 합니다. 여기서 마스터 페이지란 페이지 전반에 걸쳐서 기본 바탕이 되는 ASP.NET 사이트 수준의 골격 템플릿입니다. PowerPoint의 마스터 슬라이드와 비슷한 개념으로 이해해도 됩니다. 마스터 페이지는 Views 폴더 아래의 Shared 폴더 아래의 Site.Master 파일이며 아래와 같은 위치에 나타납니다.
7. Site.Master 파일을 열어서 아래와 같이 수정합니다. 원래 내용에서 수정된 부분을 굵게 표시하였으며 자세한 내용은 각주를 참조하여 주십시오.
<%@ Master Language="C#" Inherits="System.Web.Mvc.ViewMasterPage" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
<link href="<%= Url.Content("~/Content/Site.css") %>" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="<%= Url.Content("~/Scripts/jquery-1.4.1.min.js") %>"></script>
<script type="text/javascript" src="<%= Url.Content("~/Scripts/jquery-jtemplates.js") %>"></script>
</head>
<body>
<div class="page">
<div id="header">
<div id="title">
<h1>내 MVC 응용 프로그램</h1>
</div>
<div id="logindisplay">
<% Html.RenderPartial("LogOnUserControl"); %>
</div>
<div id="menucontainer">
<ul id="menu">
<li><%: Html.ActionLink("홈", "Index", "Home")%></li>
<li><%: Html.ActionLink("정보", "About", "Home")%></li>
</ul>
</div>
</div>
<div id="main">
<asp:ContentPlaceHolder ID="MainContent" runat="server" />
<div id="footer">
</div>
</div>
</div>
</body>
</html>
8. 웹 페이지를 위한 기본 준비는 끝났습니다. 이제 Twitter Style의 방명록을 입력받을 수 있고 보여줄 수 있는 서비스를 만들기 위하여 서비스의 중심이 되는 Controller를 구성해보도록 하겠습니다. 편의를 위하여 HomeController를 편집하도록 하겠습니다. 솔루션 탐색기에서 TwistBook.WebRole 프로젝트의 Controllers 폴더 아래의 HomeController.cs 파일을 아래 그림과 같이 선택하여 엽니다.
9. ASP.NET MVC에서 컨트롤러 내에서 Public 접근자로 노출된 각각의 Method는 이전의 ASP.NET Web Form에 비유하였을 때 개별 처리기 (ASHX 파일)에서 웹 페이지를 결정하여 내보내는 것과 같은 개념으로 최초에 사용자가 페이지에 접근할 때나, 페이지의 FORM 태그로부터 응답이 되돌아온 시점에서 모두 사용이 가능합니다. 이러한 특성을 바탕으로, HomeController는 그 자체로 API의 역할을 수행할 수 있으며 역으로 페이지를 렌더링하기 위한 컨텐츠 단위로서의 역할도 수행이 가능합니다.
HomeController.cs 파일의 내용을 아래와 같이 수정합니다.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;
using TwistBook.DataModel;
namespace TwistBook.WebRole.Controllers
{
[HandleError]
public class HomeController : Controller
{
public HomeController()
: base()
{
}
public ActionResult Index()
{
ViewData["Message"] = "Windows Azure 방명록 예제";
return View("Index");
}
[HttpPost]
public ActionResult RetrieveMessages()
{
var account = CloudStorageAccount.DevelopmentStorageAccount;
var dataSource = new TwistDataSource();
var results = from eachItem in dataSource.Select()
orderby eachItem.WrittenDate descending
select eachItem;
return Json(results);
}
[HttpPost]
public ActionResult AddMessage(string name, string message, string imageUrl)
{
var account = CloudStorageAccount.DevelopmentStorageAccount;
var dataSource = new TwistDataSource();
dataSource.Insert(new TwistModel()
{
WriterName = name,
WrittenDate = DateTime.Now,
MessageBody = message,
ImageUrl = imageUrl
});
return Index();
}
public ActionResult UpdateMessage(string partitionKey, string rowKey)
{
var account = CloudStorageAccount.DevelopmentStorageAccount;
var dataSource = new TwistDataSource();
var results = from eachItem in dataSource.Select()
where eachItem.PartitionKey == partitionKey
where eachItem.RowKey == rowKey
select eachItem;
ViewData["PartitionKey"] = partitionKey;
ViewData["RowKey"] = rowKey;
if (results.Count() > 0)
{
var result = results.First();
ViewData["Name"] = result.WriterName;
ViewData["Message"] = result.MessageBody;
}
return View();
}
[HttpPost]
public ActionResult UpdateMessage(string partitionKey, string rowKey, string name, string message, string imageUrl)
{
var account = CloudStorageAccount.DevelopmentStorageAccount;
var dataSource = new TwistDataSource();
var results = from eachItem in dataSource.Select()
where eachItem.PartitionKey == partitionKey
where eachItem.RowKey == rowKey
select eachItem;
if (results.Count() > 0)
{
var result = results.First();
if (result != null)
{
result.WriterName = name;
result.MessageBody = message;
result.WrittenDate = DateTime.Now;
result.ImageUrl = imageUrl;
dataSource.Update(result);
return View("PopupUpdateView");
}
else
return View("PopupUpdateFailView");
}
else
return View("PopupUpdateFailView");
}
public ActionResult DeleteMessage(string partitionKey, string rowKey)
{
var account = CloudStorageAccount.DevelopmentStorageAccount;
var dataSource = new TwistDataSource();
var results = from eachItem in dataSource.Select()
where eachItem.PartitionKey == partitionKey
where eachItem.RowKey == rowKey
select eachItem;
if (results.Count() > 0)
{
dataSource.Delete(results.First());
return Index();
}
else
return Index();
}
public ActionResult About()
{
return View();
}
}
}
10. 방명록의 기본 기능을 만들기 위하여 이제 Views 폴더 아래의 Home 폴더 아래의 Index.aspx 파일을 열어서 편집해야 합니다. 아래 그림과 같은 위치에 존재합니다.
<%@ Page Language="C#" MasterPageFile="~/Views/Shared/Site.Master" Inherits="System.Web.Mvc.ViewPage" %>
<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
홈 페이지
</asp:Content>
<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">
<script type="text/javascript">
$(document).ready(function () {
$.ajax({
type: 'POST',
url: '<%= Url.Action("RetrieveMessages") %>',
data: '{}',
contentType: 'application/json; charset=utf-8',
dataType: 'json',
success: function (data) {
var targetDiv = $('#guestbookList');
targetDiv.setTemplate($('#templateContent').html());
targetDiv.processTemplate(data);
}
});
});
</script>
<script type="text/html" id="templateContent">
{#foreach $T as record}
<div style="padding-bottom: 5px;">
<img src="{$T.record.ImageUrl}" alt="" style="float: left; width: 100px;" />
<div style="float: left; margin: 5px 5px 5px 5px;">
<h3>RT @{$T.record.WriterName} {$T.record.MessageBody}</h3>
<pre>{$T.record.WrittenDate} via cloud</pre>
<a href="#" onclick="window.open('<%= Url.Content("~/Home/UpdateMessage") %>?partitionKey={$T.record.PartitionKey}&rowKey={$T.record.RowKey}', 'editWindow', 'location=1,status=1,scrollbars=1,width=300,height=200');">편집</a>
|
<a href="<%= Url.Content("~/Home/DeleteMessage") %>?partitionKey={$T.record.PartitionKey}&rowKey={$T.record.RowKey}" target="_self">삭제</a>
</div>
<div style="clear: both;"></div>
</div>
{#/for}
</script>
<h2><%= ViewData["Message"] %></h2>
<div>
<div>
<% using (var form = Html.BeginForm("AddMessage", "Home", FormMethod.Post))
{ %>
<%: Html.Label("이름") %>
<%: Html.TextBox("name", "What is your name?") %>
<br />
<%: Html.TextArea("message", "Type your message here.", 3, 100, null) %>
<br />
<input type="submit" value="보내기" /> <input type="reset" value="초기화" />
<br />
<% } %>
</div>
<br /><br />
<div id="guestbookList"></div>
</div>
</asp:Content>
11. 방명록 내용을 편집하기 위한 팝업 창을 위한 뷰와, 댓글 편집이 끝난 뒤 취할 동작을 프로그래밍한 자바스크립트 코드를 위한 뷰는 Partial View로 디자인해야 합니다. 이 중에서 우선 방명록 항목 편집을 위한 Partial View를 추가하기 위해, 솔루션 탐색기에서 Views 디렉터리 아래의 Home 디렉터리를 오른쪽 버튼으로 클릭하고, View 추가 메뉴를 아래 그림과 같이 선택합니다.
12. View의 이름은 UpdateMessage로 지정하고, Partial View에 체크하여 아래 대화 상자와 같이 옵션을 구성한 후 확인 버튼을 클릭합니다.
13. UpdateMessage.ascx 파일의 내용을 다음과 같이 작성합니다.
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<dynamic>" %>
<div>
<% using (var form = Html.BeginForm("UpdateMessage", "Home", FormMethod.Post))
{ %>
<%: Html.Hidden("partitionKey", ViewData["PartitionKey"]) %>
<%: Html.Hidden("rowKey", ViewData["RowKey"]) %>
<%: Html.Label("이름") %>
<%: Html.TextBox("name", (string)ViewData["Name"]) %>
<br />
<%: Html.TextArea("message", (string)ViewData["Message"], 3, 100, null) %>
<br />
<input type="submit" /> <input type="reset" />
<br />
<% } %>
</div>
14. 이어서 솔루션 탐색기에서 Views 디렉터리 아래의 Home 디렉터리를 오른쪽 버튼으로 클릭하고, View 추가 메뉴를 11단계에서와 같이 선택합니다.
15. View의 이름은 PopupUpdateView로 지정하고, Partial View에 체크하여 아래 대화 상자와 같이 옵션을 구성한 후 확인 버튼을 클릭합니다.
16. PopupUpdateView.ascx 파일의 내용을 다음과 같이 작성합니다.
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<dynamic>" %>
<script type="text/javascript">
try {
window.close();
if (window.opener && !window.opener.closed) {
window.opener.location.href = '<%= Url.Content("~/Home/Index") %>';
}
} catch (ex) {
}
</script>
17. 이어서 솔루션 탐색기에서 Views 디렉터리 아래의 Home 디렉터리를 오른쪽 버튼으로 클릭하고, View 추가 메뉴를 11단계에서와 같이 선택합니다.
18. View의 이름은 PopupUpdateFailView로 지정하고, Partial View에 체크하여 아래 대화 상자와 같이 옵션을 구성한 후 확인 버튼을 클릭합니다.
19. PopupUpdateFailView.ascx 파일의 내용을 다음과 같이 작성합니다.
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<dynamic>" %>
<h3>업데이트에 실패하였습니다.</h3>
<a href="#" onclick="window.close()">창 닫기</a>
20. 기본적인 방명록 글 남기기와 조회 기능이 올바르게 작동하는지 확인하기 위하여 시뮬레이터를 디버그 모드로 시작해야 합니다. 일반적인 응용프로그램 개발 때와 마찬가지로 F5키를 눌러서 디버그 모드로 시뮬레이터에 패키지를 배포하고 디버거를 연결할 수 있습니다. 이 때, 아래 그림과 같은 오류 메시지가 나타나면 관리자 권한이 아닌 상태에서 Visual Studio를 시작한 것이므로 Visual Studio를 종료한 뒤 "개발 도구 시작하기 및 프로젝트 생성하기" Chapter의 1단계를 참고하여 관리자 모드로 Visual Studio를 다시 시작해야 합니다.
21. 아래의 그림들에서처럼 기능들이 정상적으로 진행된다면 우선 이번 시간에 진행할 기본 기능들에 대한 소개와 작업이 끝난 것입니다.
이번 Article을 작성하면서 발견한 Windows Azure SDK 1.2에 대한 문제 한 가지
좀 더 완성에 가까워질수록 해결될 문제들 중에 한 가지가 될 예정이긴 하겠습니다만 실습하는 도중 불편함이 예상되어 제가 발견한 문제를 블로그 아티클을 통하여 미리 공유하고자 합니다. 간혹 Windows Azure Local Storage의 Table Storage에 아래와 같이 MBCS (Multi-Byte Character Set) 문자가 포함된 데이터를 삽입하려고 할 때 별 다른 까닭없이 HTTP/404 오류가 나타나는 경우가 있습니다.
사용자 코드에서 System.Data.Services.Client.DataServiceRequestException이(가) 처리되지 않았습니다.
Message=이 요청을 처리하는 동안 오류가 발생했습니다.
Source=Microsoft.WindowsAzure.StorageClient
StackTrace:
위치: Microsoft.WindowsAzure.StorageClient.Tasks.Task`1.get_Result()
위치: Microsoft.WindowsAzure.StorageClient.Tasks.Task`1.ExecuteAndWait()
위치: Microsoft.WindowsAzure.StorageClient.TaskImplHelper.ExecuteImplWithRetry[T](Func`2 impl, RetryPolicy policy)
위치: Microsoft.WindowsAzure.StorageClient.TableServiceContext.SaveChangesWithRetries(SaveChangesOptions options)
위치: Microsoft.WindowsAzure.StorageClient.TableServiceContext.SaveChangesWithRetries()
위치: TwistBook.DataModel.TwistDataSource.Insert(TwistModel model) 파일 d:\users\남정현\documents\visual studio 2010\Projects\TwistBook\TwistBook.DataModel\TwistDataSource.cs:줄 42
위치: TwistBook.WebRole.Controllers.HomeController.AddMessage(String name, String message, String imageUrl) 파일 d:\users\남정현\documents\visual studio 2010\Projects\TwistBook\TwistBook.WebRole\Controllers\HomeController.cs:줄 44
위치: lambda_method(Closure , ControllerBase , Object[] )
위치: System.Web.Mvc.ActionMethodDispatcher.Execute(ControllerBase controller, Object[] parameters)
위치: System.Web.Mvc.ReflectedActionDescriptor.Execute(ControllerContext controllerContext, IDictionary`2 parameters)
위치: System.Web.Mvc.ControllerActionInvoker.InvokeActionMethod(ControllerContext controllerContext, ActionDescriptor actionDescriptor, IDictionary`2 parameters)
위치: System.Web.Mvc.ControllerActionInvoker.<>c__DisplayClassd.<InvokeActionMethodWithFilters>b__a()
위치: System.Web.Mvc.ControllerActionInvoker.InvokeActionMethodFilter(IActionFilter filter, ActionExecutingContext preContext, Func`1 continuation)
InnerException: System.Data.Services.Client.DataServiceClientException
Message=<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<error xmlns="http://schemas.microsoft.com/ado/2007/08/dataservices/metadata">
<code>InvalidInput</code>
<message xml:lang="ko-KR">One of the request inputs is not valid.</message>
</error>
Source=System.Data.Services.Client
StatusCode=400
StackTrace:
위치: System.Data.Services.Client.DataServiceContext.SaveResult.<HandleBatchResponse>d__1e.MoveNext()
InnerException:
실제 Windows Azure 실행 환경에서는 이러한 현상이 나타나지 않는 것으로 보입니다. 추후, 이러한 문제점을 해결할 수 있는 방안이 발견되면 별도의 업데이트 소식을 통하여 정보가 전달될 수 있도록 하겠습니다. 예제를 기반으로 테스트 패브릭 위에서 테스트하시는 동안에는 Table Storage에 한글, 히라가나, 카타카나, 번체, 간체, 한자 등의 데이터가 들어가지 않는 범위에서 테스트가 필요할 것 같습니다.
다음 시간에는
다음 시간에는 각 Role이 어떤 방법으로 Windows Azure 환경에서 실행되는지, Web Role과 Worker Role이 Cloud Computing 환경에서 상호 작용하고 통신하는 방법을 본격적으로 소개하고, 오늘 만든 Web Role을 어떤 방식으로 수정하게 될 것이고, Worker Role이 어떤 방식으로 데이터를 교환하게 될 것인지를 보여드릴 예정입니다. 그리고 이번 시간에 언급하지 않은 BLOB Storage에 이미지를 저장하고 가져오는 방법에 대해서도 소개하겠습니다. :-)
더운 여름 날씨에 건강 유의하시고, 활기찬 여름 보내시기 바랍니다. 감사합니다.
ps. Windows Azure Cafe (http://cafe.naver.com/wazure) 에서 2010년 8월 14일부터 본격적으로 Offline Study를 진행합니다. Windows Azure Platform의 학습에 관심있으신 개발자 여러분들의 많은 관심과 참여 부탁드리며, 아울러 Visual Studio 2010 공식 팀 블로그에서 Cloud Computing 관련 Article을 집필하실 열정적인 Blogger 여러분도 함께 모시고 있습니다. 이에 관련된 모든 상세한 내용은 Windows Azure Cafe를 통하여 저에게 연락 주시면 상세히 안내해드리겠습니다. 감사합니다. :-)