2012年10月25日

[Azure] 上傳VHD到Azure作為Image

其實這個Topic在網路上可以找到一堆文章,不過自己做一次還是比較有安心的感覺:)

首先準備一個VM,然後進入系統。用Administrator權限執行sysprep,他的位置在:C:\windows\system32\sysprep

image

選擇BBOC,做完以後關機。按下OK就可以讓他跑下去了。

image

跑這個需要一段時間,我們先放下他來準備環境。

為了測試,首先我到Windows Azure Portal上建立一個測試用的Storage。

image

接著我們需要以Administrator權限來執行一些command line命令。

image

進入後,首先我們需要建立一張憑證來做為上船憑證,切換到C:\Program Files (x86)\Windows Kits\8.0\bin\x64,目錄位置可能會依環境不同而不同,找不到就直接搜尋makecert。用下列的命令建立一張憑證。

makecert –sky exchange –r –n “CN=VHDUploadTest” –pe –a –sha1 –len 2048 –ss My “VHDUploadTest.cer”

其中,CN=XXX與cer檔名都是可以改掉的。

image

接著回到Windows Azure Portal,把這張憑證上傳到Azure。

image

回到command line,切換目錄到Windows Azure SDK\bin。

image

首先要指定Connection String

csupload Set-Connection “SubscriptionID=xxx;CertificateThumbprint=xxx;ServiceManagementEndpoint=https://management.core.windows.net”

其中,Subscription ID是訂閱的Subscription ID,CertificateThumbprint則是剛剛那張憑證的Thumbprint。

image

設定完成後用下面這個指令上傳VHD到Azure。

csupload Add-PersistentVMImage –Destination “.blob.core.windows.net//">http://<StorageName>.blob.core.windows.net/<VHDFolder>/<VHDNAME>” –Label <VHDNAME> -LiteralPath F:\VHD\VMUpload.vhd -OS Windows

其中,<StorageName>是剛剛建立的Storage名稱,<VHDFolder>是上傳後的VHD Container名稱,<VHDName>則是VHD的名稱。

然後就會開始上傳了。

image

2012年10月24日

[Azure] Windows Media Service

Windows Media Service提供我們建置、管理和發布媒體的工作流程。由於目前還在Preview階段,要使用Media Service,首先需要到網站上申請試用。

image

待申請通過後,就可以到Windows Azure網站上建立一個新的Media Service了。建立的過程跟建立其他Azure服務一樣相當簡單,只需要注意DNS名稱不要重複就好了,建立完後進入Media Serivce頁面,可以在下圖圈起來的地方下載及安裝Media Service SDK。要注意的是,Media Service SDK還會另外需要WCF Data Service for OData 的功能,這個得要另外下載

image

安裝完成之後,就可以開始來踹踹看啦。

首先打開Visual Studio,建立一個一般的WinForm程式,加入以下的reference:

  • C:\Program Files\Windows Azure SDK\v1.6\bin\
    • • Microsoft.WindowsAzure.StorageClient.dll
  • C:\Program Files (x86)\Microsoft WCF Data Services\5.0\bin\.NETFramework\
    • • Microsoft.Data.Edm.dll
    • • Microsoft.Data.OData.dll
    • • Microsoft.Data.Services.Client.dll
    • • Microsoft.Data.Services.dll
    • • System.Spatial.dll
  • C:\Program Files (x86)\Microsoft SDKs\Windows Azure Media Services\Services SDK\v1.0\
    • • Microsoft.WindowsAzure.MediaServices.Client.dll

以上是Media Service、Storage Client以及WCF Data Service for OData的必要組件,加入後還有一件事情要做。因為目前的Media SDK版本是link到Azure SDK 1.6,因此所使用的StorageClient版本是1.1.0.0版的,如果我們的Azure SDK是1.6以上的版本的話,需要用binding redirect的方式把StorageClient組件指到新版本。在app.config裡加入這一段設定。

  <runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.WindowsAzure.StorageClient"
publicKeyToken="31bf3856ad364e35"
culture="neutral" />
<bindingRedirect oldVersion="1.1.0.0"
newVersion="1.7.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.WindowsAzure.StorageClient"
publicKeyToken="31bf3856ad364e35"
culture="neutral" />
<publisherPolicy apply="no" />
</dependentAssembly>

</assemblyBinding>
</runtime>



首先,我想要上傳一個檔案到Media Service上,並且利用Media Service的轉檔功能幫我將檔案轉為MP4的格式。為此,我宣告一個MediaContext類別,讓這個類別來幫我處理一切的瑣事。



    public class MediaContext
{
const string AccountKey = "===key===";
const string AccountName = "===name==="
CloudMediaContext context = null;
public MediaContext()
{
context = new CloudMediaContext(AccountName,AccountKey);
}
//...



建立Media Context需要的Account Name與Account Key就是剛剛我們在Portal上建立的Media Service的Name與Key,可以在這裡拿到。



image



把程式碼中的Key與Name替換掉就可以了。



接著,我要想辦法把檔案上傳到Media Service中,並且讓他幫我轉檔。在Media Service裡,需要Media Service處理的事情都需要包成一個一個的Task,然後把這些Task組成一個Job丟給Media Service讓他幫我們做。因此在這裡,我先讓使用者選擇檔案,然後取得MediaProcessor作為轉檔的Processor。



//各種Media Service提供的MediaProcessor定義
public const string WindowsAzureMediaEncoder = "Windows Azure Media Encoder";
public const string PlayReadyProtectionTask = "PlayReady Protection Task";
public const string MP4toSmoothStreamsTask = "MP4 to Smooth Streams Task";
public const string SmoothStreamstoHLSTask = "Smooth Streams to HLS Task";
public const string StorageDecryption = "Storage Decryption";
//建立一個Asset,一個Asset可以視為一堆檔案的集合,可能包含媒體檔案或是設定檔等等
public IAsset CreateAsset(string localPath, bool requireEncrypt = true)
{
return context.Assets.Create(localPath, requireEncrypt ? AssetCreationOptions.StorageEncrypted : AssetCreationOptions.None);
}
//取得指定的Media Processor
private IMediaProcessor GetMediaProcessor(string name)
{
var processors = from proc in context.MediaProcessors
where proc.Name == name
select proc;
var processor = processors.FirstOrDefault();
if (processor != null)
{
return processor;
}
else
{
throw new NullReferenceException();
}
}
public void CreateNonStreamingJob(string inputFilePath, string outputFile)
{
//建立一個Asset,指定StorageEncrypt表示我們想把檔案內容在傳輸過程中壓密起來
var asset = context.Assets.Create(inputFilePath, AssetCreationOptions.StorageEncrypted);
//建立一個Job
var job = context.Jobs.Create(string.Format("ingestasset_{0}", DateTime.UtcNow.ToString("yyyyMMddHHmmssffffff")));
//把一個Task加入這個Job,指定Media Processor與configuration
var task = job.Tasks.AddNew("ingest", GetMediaProcessor(MediaProcessors.WindowsAzureMediaEncoder), "H.264 256k DSL CBR", TaskCreationOptions.ProtectedConfiguration);
//然後把input asset加進去,這就是我們要轉檔的來源Asset
task.InputMediaAssets.Add(asset);
//指定Output Asset,這就是轉檔結果的Asset
task.OutputMediaAssets.AddNew(outputFile, true, AssetCreationOptions.None);
//把Job丟給Media Service
job.Submit();
}


這樣Media Service就會開始幫我們轉檔了。透過Azure Storage Explorer瀏覽剛剛我們建立Media Service時指定的Storage,會看到一個一個Blob被建立。



image



轉檔可能會需要一段時間,這中間我們可以透過以下的程式碼來檢查Job的狀態。



        public string [] GetJobStatus()
{
List<string> status = new List<string>();
foreach(var job in context.Jobs){
if (job.State == JobState.Error)
{
status.Add(string.Format("* job {0} is {1}", job.Id, string.Join("\r\n", job.Tasks.Select(x => string.Join("\r\n", x.ErrorDetails.Select(c => c.Code + "-" + c.Message).ToArray())))));
}
else
{
status.Add(string.Format("* Job {0} is {1}", job.Name, job.State.ToString()));
}
}
return status.ToArray();
}




當然也可以刪除執行結果有問題的Job



        //刪掉失敗的Job
public void DeleteFailedJobs()
{
var jobs = (from job in context.Jobs
where job.State == JobState.Error
select job);
if (jobs != null)
{
foreach (var job in jobs)
job.Delete();
}
}


值得注意的是,目前SDK的物件對於Linq的支援度不大一致,有時會遇到不支援Linq語法的錯誤訊息,這時就只好袖子捲起來用一般的迴圈來處理了。



待轉檔完成,就可以把檔案下載到本機電腦了。



        public string [] DownloadtoLocal(string localFolder)
{
foreach (var job in context.Jobs)
{
if (job.State == JobState.Error)
continue;
foreach (var asset in job.OutputMediaAssets)
{
foreach (var file in asset.Files)
{
file.DownloadToFile(Path.Combine(localFolder, file.Name));
}
}
}
return null;
}

接著我們來試試看產生Smooth Stream。首先當然還是要建立一個Job。

        public void CreateStreamingJob(string inputPath, string outputPath)
{
var asset = context.Assets.Create(inputPath, AssetCreationOptions.StorageEncrypted);
var job = context.Jobs.Create("streamingJob_" + DateTime.UtcNow.ToString("yyyyMMddHHmmssffffff"));
//讀取config,這個檔案內容如下
var config = File.ReadAllText("config.xml");
var task = job.Tasks.AddNew("ingest_streaming", GetMediaProcessor(MediaProcessors.MP4toSmoothStreamsTask),
config,
TaskCreationOptions.ProtectedConfiguration);
task.InputMediaAssets.Add(asset);
var outputAsset = task.OutputMediaAssets.AddNew("stream_" + inputPath, true, AssetCreationOptions.None);
job.Submit();
}

一切都跟一般的轉檔差不多,只有Configuration的地方不大一樣,這邊可以用以下這個檔案內容船進去就可以了。

<taskDefinition xmlns="http://schemas.microsoft.com/iis/media/v4/TM/TaskDefinition#">
<name>MP4 to Smooth Streams</name>
<id>5e1e1a1c-bba6-11df-8991-0019d1916af0</id>
<description xml:lang="en">Converts MP4 files encoded with H.264 (AVC) video and AAC-LC audio codecs to Smooth Streams.</description>
<inputFolder />
<properties namespace="http://schemas.microsoft.com/iis/media/V4/TM/MP4ToSmooth#" prefix="mp4">
<property name="keepSourceNames" required="false" value="true" helpText="This property tells the MP4 to Smooth task to keep the original file name rather than add the bitrate bitrate information." />
</properties>
<taskCode>
<type>Microsoft.Web.Media.TransformManager.MP4toSmooth.MP4toSmooth_Task, Microsoft.Web.Media.TransformManager.MP4toSmooth, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35</type>
</taskCode>
</taskDefinition>



最後,我們要取得轉檔完成後,Client存取的URL。



        public void RevokeAllLocators()
{
foreach (var locator in context.Locators)
{
context.Locators.Revoke(locator);
}
}
//測試的結果,一個Asset最多只能同時關連到五個Access Policy
//因此,這邊我每次要指定新的Policy時,就先刪掉舊的
public void DeleteAllPolicies()
{
var existingPolicies = context.AccessPolicies;
foreach (var policy in existingPolicies)
{
context.AccessPolicies.Delete(policy);
}
}

public string[] ListStreamURLs()
{
List<string> ret = new List<string>();
//找到所有的Asset
foreach (var asset in context.Assets)
{
//副檔名為.ism的檔案就是提供Client Streaming的檔案
var ism = from file in asset.Files
where file.Name.EndsWith(".ism")
select file;

var manifist = ism.FirstOrDefault();

if (manifist == null)
continue;
//如果已經有Locator了,先刪掉他
//Locator會提供client存取的位置
foreach (var existingLocator in context.Locators)
{
context.Locators.Revoke(existingLocator);
}
//如果有已經存在的Access Policy,先刪掉他
var existingPolicies = context.AccessPolicies.Where(p => p.Name == "Streaming_Access_Policy_" + manifist.Name);
foreach (var policy in existingPolicies)
{
context.AccessPolicies.Delete(policy);
}
//建立新的Policy,開放存取五天
var accessPolicy = context.AccessPolicies.Create("Streaming_Access_Policy_" + manifist.Name, TimeSpan.FromDays(5), AccessPermissions.Read);
//建立新的Locator,從UTC時間的昨天起算
var urlLocator = context.Locators.CreateOriginLocator(asset, accessPolicy, DateTime.UtcNow.AddDays(-1));
//用這種方式組成URL
string urlForClientStreaming = urlLocator.Path + manifist.Name + "/manifest";
ret.Add(urlForClientStreaming);
}
if (ret.Count == 0)
return null;
return ret.ToArray();
}









產生出來的URL會長得像這樣子http://wamssinreg001orig-hs.cloudapp.net/37a39a1b-19fb-465b-a011-b121a61b784c/IMG_2354.ism/manifest



我們可以把這串網址貼到這裡來試試看是不是一切正常。

2012年10月15日

[Azure]在Work Role安裝軟體

其實沒那麼複雜,不過還是try了一下子,因此還是記錄下來。

這次要安裝的程式有兩個,Office 365 Powershell add-on跟O365登入小幫手。目的是要讓Work Role每一段時間就執行一些Office 365的administration作業。

一樣新增兩個Startup task在csdef裡。我用Role Content Folder來佈署我的安裝檔和相關作業的script檔案。

image

install1.cmd負責安裝Office 365 add-on,install2.cmd負責安裝登入小幫手。當然可以寫在一起,不過為了測試的目的,我先把這兩個分開。在Install1.cmd裡執行這個指令,因為Startup時執行目錄是Work Role所在目錄, 也就是%Root%\approot下, 因此這邊要用相對路徑指定msi檔的位置。/qn表示不要使用UI,並且把執行結果寫進msoidcrl-new.txt裡。

msiexec /qn /l* msoidcrl-new.txt /i Scripts\msoidcrl.msi




Update 2012/10/16 - 如果需要配和Powershell使用時, 由於預設系統對powershell script的權限是設定為allsigned, 我們通常需要在startup task中改為remotesigned或是unrestrict, 在Windows 2008 R2相容的Guest OS(也就是cscfg中osFamily = 2), 會使用Set-ExecutionPolicy remotesigned的方式來設定. 但這個指令需要較大的權限, 因此務必要在Startup Task中以elevated executionContext執行. 這個context會以Local System帳號權限執行.

2012年10月8日

[Azure]使用Windows Azure ACS與公司網路的AD做SSO整合–Part 1

我只能說…這真是一個漫長而充滿挑戰的過程…

首先, 準備一組AD環境來模擬公司網路的AD環境. 這邊我使用Windows Server 2012, 安裝AD DS, DNS, DHCP, Web Server在同一台機器上. 網域名稱是michaeldns.biz, Domain Controller電腦FQDN是pdc.michaeldns.biz.

由於目前還沒有要開始寫程式, 我只是單純地想要測試由Windows Azure ACS來整合內網的AD, 因此這邊我會先用一個根本不存在的網站來測試如何安裝與設定ADFS與Azure ACS, 預期是我應該要可以用預設的登入頁面來假裝要登入這個網站, 網站應該要向我要AD的帳密, 我驗證完成後, Azure應該要把我導到這個不存在的網站, 所以我雖然會得到一個HTTP 404錯誤, 但是應該要在IE上看到整個過程完成.

照著一般標準步驟做完dcpromo, 確定一切正常之後, 就可以準備安裝ADFS了.(這個步驟視情況大約需要1~2小時). 我在家裡的環境為了測試從外網接回AD Server大概多花了一個小時.

接著要來設定IIS的HTTPS憑證, 由於是測試, 這邊使用Self-Signed憑證來作為SSL連線使用的憑證. 進入IIS管理員, 左邊點選本台機器, 右邊雙擊Server Certificates

image

填上Friendly Name, 然後確定Certificate Store選到Personal後按確定即可

image

完成圖

image

 

接下來要開始安裝ADFS 2.0. 在2012 Server上安裝ADFS 2.0只需要從Server Manager中新增AD Federation Service這個Role就可以了, 安裝過程沒有甚麼需要特別注意的地方, 只要一直下一步就可以了 (不需要安裝ADFS 1.0相關的服務, 也不需要安裝ADFS Proxy). 安裝完成後可以在Server Manager的Dashboard上看到AD FS已經安裝完成

image

接著我們要來設定ADFS, 從這邊開啟ADFS管理員

image

點這裡開始設定

image

這裡直接按下一步, 因為現在是第一台ADFS伺服器

image

這邊可以選擇Standalone伺服器或是準備建立Federation Server Farm, 這裡我選擇Federation Server Farm然後按下一步

image

由於剛剛已經先設定好了IIS的SSL, 這裡系統會自動抓到剛剛設定的憑證, 按下一步.

image

我是重新安裝, 因此這裡可以把舊的資料庫刪除以確保都是新的資料

image

選擇Service Account, 如果是建立Server Farm的話, 所有的Service都需要使用這個帳號.

image

按下一步就會開始設定了

image

視情況會出現一兩個警告. 第一個是說host/[Computer FQDN]這個SPN已經存在了. 第二個是說因為有照到之前設定的ADFS網站, 因此安裝程式沒有再建立一個新的. 這邊可以直接忽略.

image

如此初步的設定就完成了, 稍後我們還會需要回來這裡做ADFS的設定.

現在, 讓我們到Windows Azure Portal上來設定ACS

進入舊版管理網站後, 先建立一個ACS Namespace. 建立的過程相當直觀, 選好區域跟名稱案確定就可以了, 我的測試namespace是MichaelVirtualNet

image

接著選擇剛剛建立的Namespace, 按存取控制服務

image

這會讓我們進入ACS管理介面, 首先新增一個Identity Provider

image

選擇新增一個WS-同盟識別提供者

image

輸入相關資訊. 在WS-同盟中繼資料的部分可以直接從我們剛剛設定的ADFS網站來下載. 其網址為https://[HOST IP]/FederationMetadata/2007-06/FederationMetadata.xml , 或是下載後儲存在以檔案的方式上傳. 設定完成後按儲存即可.

image

接下來要新增信賴憑證者應用程式

image

在這裡會需要填上實際上的應用程式的網址, 但是現階段我根本還沒有要開始寫東西, 我只是想先確定這整個架構是正常的, 因此這邊我先隨便填一個https://vnclient.com, 稍後我會把這一塊的程式補上

image

同一個畫面下半段的部分, 基本上只有識別提供者的部分需要視需求選擇, 這裡我選擇我的網站可以使用Windows Live ID, Google ID與我們的AD進行登入

image

儲存. 接下來要設定規則群組. 可以選擇新增, 或是修改剛剛系統幫我們產生的預設群組. 這裡我選擇修改預設群組

image

點擊群組名稱就可以進入設定畫面. 整個規則設定的邏輯大約是

如果[識別提供者]是[XXX], 且[輸入宣告型別]是[OOO], 且[輸入宣告值]等於[任何值]或是特定值.

把輸入型別轉型成我想要的型別, 並且值為前端傳遞過來的值或是特定值

說起來很繞口, 其實就是在做mapping而已.

這邊我們把識別提供者Google所傳過來的http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name 這個值, 轉換為http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name (也就是直接傳遞)

設定完成大概會是這樣

image

接著到應用程式整合, 把WS-同盟中繼資料這一段copy起來.

回到ADFS管理員, 點中間這個Link

image

跳過歡迎畫面後, 把剛剛那一段網址貼到這裡來然後按下一步

image

這邊選擇Permit all users to access this relying party

image

下一步到最後, 確定這裡有打勾

image

結束剛剛的精靈之後會自動帶出Edit Claim Rules的畫面, 按下的[Add Rules]

image

因為要整合AD, 這邊選擇Send LDAP Attributes as Claims

image

隨便給一個rule name, 在Attribute Store的地方選擇Active Directory. 然後Mapping of LDAP attribute to outgoing claim types的地方新增一筆把Token-Groups – Unqualified Name對應到Role的紀錄

image

按確定結束設定.

到這邊基本上已經設定完成了. 接著要來測試一下剛剛的設定是不是都正確.因為還沒開始部署程式, 所以我們先來借用ACS Portal上的範例登入畫面來測試.

image

下載後會看到剛剛設定的三個識別提供者都出現在列表裡, 其中Michael Virtual Net是我們的AD整合. 點她後會跳出AD驗證的登入視窗, 輸入ID Password後, 會看到IE網址列會開始詢問pdc.michaeldns.biz以及Azure ACS網址, 最後驗證完成之後, 就會把我們導向剛剛所設定的那個不存在的網址(http://vnclient.ocm). 到這邊應該可以認定設定應該都沒有問題了, 接下來就要實際上寫個程式放到雲端上來跟AD整合了.

image

2012年10月2日

[Azure]Configure Custom DNS Name for Azure Website

To configure custom DNS Name for Azure Website. First, of course, we’ll have to configure CName or A Record in our DNS register.
Say I have a Azure website with its FQDN http://michaeltestdns2.azurewebsites.net/ and I want users to use http://cloudworker.michaeldns.biz instead where michaeldns.biz is my root DNS.
For testing purpose, I’ve setup my GoDaddy as following.
clip_image002
I also setup forwarding as the instruction here.
Then go to Azure Management Portal, click on the website we want to setup. Click “Manage Domain” button in the bottom of the page. And set the following
image
Note that when trying to setup custom DNS, Azure will check public DNS for the FQDN you are trying to setup (in my case – cloudworker.michaeldns.biz). If Azure cannot resolve the provided DNS Name, the setup will fail.

2012年10月1日

[Azure]Invoking WCF Service hosted in a WebRole on Internal Endpoint

Say I have a WCF service hosted in a web role, and it is for application internal uses only – that is, no public access to that service.

To communicate with WCF thru an internal endpoint basically is to write our own service host and provide custom client configuration.

To do this, first we declare an Internal endpoint in VS Role setting for that specific WebRole.

image

Open WCF Service project, we then need to add codes to host our service in WebRole.cs.

namespace SQLUserMgmtSvc
{
public class WebRole : RoleEntryPoint
{
// service host instance
private ServiceHost _host = null;
private void SetupTcpEndpoint()
{
//host Notification service
_host = new ServiceHost(typeof(Notification));
_host.Faulted += (sender, e) =>
{
Trace.TraceError("Host fail!");
_host.Abort();
Trace.TraceError("Host aborted");
};
//we'll expose this service via NetTcpBinding
NetTcpBinding tcpBinding = new NetTcpBinding(SecurityMode.None);
//Retrieve endpoint setting via RoleEnvironment
RoleInstanceEndpoint internalEP =
RoleEnvironment.CurrentRoleInstance.InstanceEndpoints["InternalServiceEP"];
//add endpoint to service host
_host.AddServiceEndpoint(typeof(INotification), tcpBinding,
string.Format("net.tcp://{0}/Notification.svc", internalEP.IPEndpoint));
//start listening
_host.Open();
}
public override void OnStop()
{
_host.Close();
base.OnStop();
}
public override bool OnStart()
{
// To enable the AzureLocalStorageTraceListner
// uncomment relevent section in the web.config
//...omitted...

// For information on handling configuration changes
// see the MSDN topic at http://go.microsoft.com/fwlink/?LinkId=166357.
SetupTcpEndpoint();
return base.OnStart();
}
}
}



Then on the client side, create a proxy via ChannelFactory class



        private static CloudSVC.INotification GetNotificationService()
{
////Create with basic http binding
//ChannelFactory<CloudSVC.INotification> factory =
// new ChannelFactory<CloudSVC.INotification>
// ("BasicHttpBinding_INotification");
//return factory.CreateChannel();

////create net.tcp internal endpoint
var remoteAddress =
RoleEnvironment.Roles["SQLUserMgmtSvc"].Instances[0].InstanceEndpoints["InternalServiceEP"].IPEndpoint;
var remoteEndpoint = new EndpointAddress(string.Format("net.tcp://{0}/Notification.svc",remoteAddress));

ChannelFactory<CloudSVC.INotification> factory =
new ChannelFactory<INotification>(new NetTcpBinding(SecurityMode.None), remoteEndpoint);
return factory.CreateChannel();
}


This is how we communicate between Roles via Internal endpoint.


However as you can see, we are retrieving remote service address via RoleEnvironment.Roles[“ROLE”].Instance[0], meaning the service is NOT load balanced.


If you do need load balance mechanism for internal services, you have to implement that in your codes, Ryan Dunn wrote a article on this.

Blog Archive

About Me