Several years ago I had to build a custom URL shortner for a website. I recently had a task where I needed to do something similar, so I converted it to C# and thought I'd share how easy this can be.
First off you'll need a database table for the url data. In this example I'm using SQL Server 2008.
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
SET ANSI_PADDING ON
GO
CREATE TABLE [dbo].[Urls](
[PK] [int] IDENTITY(1,1) NOT NULL,
[Key] [varchar](10) COLLATE SQL_Latin1_General_CP1_CS_AS NOT NULL CONSTRAINT [DF_Urls_Key] DEFAULT (''),
[Url] [varchar](2000) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL CONSTRAINT [DF_Urls_Url] DEFAULT (''),
[CreatedBy] [varchar](100) COLLATE SQL_Latin1_General_CP1_CI_AS NOT NULL,
[CreatedDate] [smalldatetime] NOT NULL CONSTRAINT [DF_Urls_CreatedDate] DEFAULT (getdate()),
CONSTRAINT [PK_Urls] PRIMARY KEY CLUSTERED
(
[PK] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY],
CONSTRAINT [IX_Urls] UNIQUE NONCLUSTERED
(
[Key] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
SET ANSI_PADDING OFF
GO
-
PK column to act as the primary key
-
Key column is a unique nonclustered column, which is the unique key for the shortened url
-
Url column which is the fully qualified url that that the shortened url should redirect to
-
CreatedBy is a simple
varchar field to provide optional auditing
-
CreatedDate is a simple
smalldatetime field to provide optional auditing
It's worth noting that I'm using
SQL_Latin1_General_CP1_CS_AS for the collation on the
Key column. This isn't absolutely necessary; however, it means that the
Key column is case-sensitive and makes the universe much larger for the number of unique values that be be stored in that column.
The next step is to create the view and scalar-valued functions.
GetUniqueIdentifierView
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE VIEW [dbo].[GetUniqueIdentifierView]
AS
SELECT 'ID' = NEWID()
GO
GetUniqueIdentifier
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE FUNCTION [dbo].[GetUniqueIdentifier]()
RETURNS UNIQUEIDENTIFIER
AS
BEGIN
RETURN (SELECT ID FROM GetUniqueIdentifierView)
END
GO
GetRandomString
SET ANSI_NULLS OFF
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE FUNCTION [dbo].[GetRandomString]
(
@Length INT = 5
)
RETURNS VARCHAR(MAX)
AS
BEGIN
-- variables
DECLARE @Characters VARCHAR(62)
DECLARE @Output VARCHAR(MAX)
DECLARE @i INT
-- values
SET @Characters = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
SET @Output = ''
SET @i = 0
-- loop
WHILE @i < @Length
BEGIN
-- variables
DECLARE @Position INT
-- random position in character map
SET @Position = ABS(CAST(CAST(dbo.GetUniqueIdentifier() AS VARBINARY) AS INT)) % LEN(@Characters)
-- concatenate the random character map value to our string
SET @Output = @Output + SUBSTRING(@Characters, @Position + 1, 1)
-- increment loop counter
SET @i = @i + 1
END
-- return value
RETURN ISNULL(@Output, '')
END
GO
At this point we have a simple function we can call to get a random string of a specified length, for example:
SELECT dbo.GetRandomString(5)
Now we'll create the stored procedures to finish off the SQL side of things.
The first stored procedure is
Urls_List. This stored procedure has optional parameters to either return all urls, return a single url for the specified
PK, or return a single url for the specified
Key.
SET ANSI_NULLS OFF
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROCEDURE [dbo].[Urls_List]
(
@PK INT = 0,
@Key VARCHAR(10) = ''
)
AS
BEGIN
-- SET NOCOUNT ON added to prevent extra result sets from interfering with SELECT statements.
SET NOCOUNT ON;
-- get data
SELECT PK,
[Key],
Url,
'UrlShort' = '{BaseUrl}url/?' + [Key],
CreatedBy,
CreatedDate
FROM Urls
WHERE PK =
CASE
WHEN @PK > 0 THEN @PK
ELSE PK
END
AND [Key] =
CASE
WHEN LEN(@Key) > 0 THEN @Key
ELSE [Key]
END
ORDER BY PK
END
GO
Now you have a simple way to get the data from the
Urls table, for example:
-- get all urls
EXEC dbo.Urls_List
-- get url for specified PK
EXEC dbo.Urls_List @PK = 1
-- get url for specified Key
EXEC dbo.Urls_List @Key = 'gyzAq'
You'll want to pay special attention to the
{BaseUrl} reference in the
Urls_List stored procedure. You could do one of two things here. You could plug your fully qualified base url directly in there (e.g. http://pavey.me/)
OR you can keep that placeholder in there, and replace it generically in either the data layer, model object, or the web project, depending on your requirements.
The next stored procedure is
Urls_Save. This stored procedure creates the record in the
Urls table with a unique key and returns the record. If a record with the specified
Url already exists it will return that record instead of creating a new one.
SET ANSI_NULLS OFF
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE PROCEDURE [dbo].[Urls_Save]
(
@Url VARCHAR(2000),
@CreatedBy VARCHAR(100)
)
AS
BEGIN
-- declare variables
DECLARE @PK INT
-- see if url already exists
SELECT @PK = PK
FROM Urls
WHERE Url = @Url
-- handle nulls
SET @PK = ISNULL(@PK, 0)
-- create url if necessary
IF @PK < 1
BEGIN
-- variables
DECLARE @Key VARCHAR(5) = ''
DECLARE @KeyLength INT = 5
DECLARE @KeyIsUnique BIT = 0
-- get random key
SET @Key = dbo.GetRandomString(@KeyLength)
-- check to see if key already exists
IF NOT EXISTS(SELECT PK FROM Urls WHERE [Key] = @Key)
BEGIN
SET @KeyIsUnique = 1
END
-- if key is not unique keep looking
IF @KeyIsUnique = 0
BEGIN
WHILE @KeyIsUnique = 0
BEGIN
-- get random key
SET @Key = dbo.GetRandomString(@KeyLength)
-- check to see if key already exists
IF NOT EXISTS(SELECT PK FROM Urls WHERE [Key] = @Key)
BEGIN
SET @KeyIsUnique = 1
END
END
END
-- insert record
INSERT INTO Urls
([Key], Url, CreatedBy)
VALUES
(@Key, @Url, @CreatedBy)
-- get identity
SET @PK = SCOPE_IDENTITY()
END
-- return url
EXEC Urls_List @PK = @PK
END
GO
You'll notice in the
Urls_Save stored procedure I'm using
@KeyLength = 5. The
GetRandomString function we created can handle creating a random string of any size, so you'll just want to set this based on your needs. In my case, a unique key length of 5 was sufficient.
This takes care of the SQL side of things. Now we are going to create a model class to represent our url data. I keep my model classes in a
Model project, and I use a
BaseModel class, which for purposes of this example is empty, but gives you a place to provide base support for your model classes.
BaseModel.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sandbox.Model
{
public class BaseModel : IDisposable
{
#region "IDisposable Support"
// To detect redundant calls
private bool disposedValue;
// IDisposable
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
// TODO: dispose managed state (managed objects).
}
// TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
// TODO: set large fields to null.
}
this.disposedValue = true;
}
// TODO: override Finalize() only if Dispose(ByVal disposing As Boolean) above has code to free unmanaged resources.
//Protected Overrides Sub Finalize()
// ' Do not change this code. Put cleanup code in Dispose(ByVal disposing As Boolean) above.
// Dispose(False)
// MyBase.Finalize()
//End Sub
// This code added by Visual Basic to correctly implement the disposable pattern.
public void Dispose()
{
// Do not change this code. Put cleanup code in Dispose(ByVal disposing As Boolean) above.
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
}
UrlShort.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sandbox.Model
{
public class UrlShortner : BaseModel
{
// constructor
public UrlShortner()
{
PK = 0;
Key = string.Empty;
Url = string.Empty;
UrlShort = string.Empty;
CreatedBy = string.Empty;
CreatedDate = DateTime.MinValue;
}
// public properties
public int PK { get; set; }
public string Key { get; set; }
public string Url { get; set; }
public string UrlShort { get; set; }
public string CreatedBy { get; set; }
public DateTime CreatedDate { get; set; }
}
}
Now we'll create the data layer class. I keep the data layer classes in a
Data project, and I use a
BaseDB class, which for purposes of this example is empty, but gives you a place to provide base support for your data layer classes. In this example we're using
Microsoft Enterprise Library 5.0 – May 2011 to provide the data access.
BaseDB.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Sandbox.Data
{
public class BaseDB : IDisposable
{
#region "IDisposable Support"
// To detect redundant calls
private bool disposedValue;
// IDisposable
protected virtual void Dispose(bool disposing)
{
if (!this.disposedValue)
{
if (disposing)
{
// TODO: dispose managed state (managed objects).
}
// TODO: free unmanaged resources (unmanaged objects) and override Finalize() below.
// TODO: set large fields to null.
}
this.disposedValue = true;
}
// TODO: override Finalize() only if Dispose(ByVal disposing As Boolean) above has code to free unmanaged resources.
//Protected Overrides Sub Finalize()
// ' Do not change this code. Put cleanup code in Dispose(ByVal disposing As Boolean) above.
// Dispose(False)
// MyBase.Finalize()
//End Sub
// This code added by Visual Basic to correctly implement the disposable pattern.
public void Dispose()
{
// Do not change this code. Put cleanup code in Dispose(ByVal disposing As Boolean) above.
Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
}
}
Urls.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Data;
using System.Data.Common;
using Microsoft.Practices.EnterpriseLibrary.Data;
using Sandbox.Utilities;
namespace Sandbox.Data
{
public class Urls : BaseDB
{
public Model.UrlShortner Get(int PK)
{
// validate
if (PK < 1)
{
return new Model.UrlShortner();
}
// lookup
List<Model.UrlShortner> x = List(PK: PK);
// return
if (x.Count > 0)
{
return x.First();
}
else
{
return new Model.UrlShortner();
}
}
public Model.UrlShortner Get(string Key)
{
// validate
if (Key.IsBlank())
{
return new Model.UrlShortner();
}
// lookup
List<Model.UrlShortner> x = List(Key: Key);
// return
if (x.Count > 0)
{
return x.First();
}
else
{
return new Model.UrlShortner();
}
}
public List<Model.UrlShortner> List(Int32 PK = 0, string Key = "")
{
// variables
Database DB = DatabaseFactory.CreateDatabase();
List<Model.UrlShortner> Urls = new List<Model.UrlShortner>();
// command
using (DbCommand cmd = DB.GetStoredProcCommand("dbo.Urls_List"))
{
// parameters
DB.AddInParameter(cmd, "@PK", DbType.Int32, PK);
DB.AddInParameter(cmd, "@Key", DbType.String, Key);
// execute query and get results
using (DataTable DT = new DataTable())
{
using (IDataReader IDR = DB.ExecuteReader(cmd))
{
if (IDR != null)
{
DT.Load(IDR);
}
}
// convert to business object
Urls = DT.ToList<Model.UrlShortner>().ToList();
}
}
// return list
return Urls;
}
public Model.UrlShortner Save(string Url, string CreatedBy = "")
{
// variables
Database DB = DatabaseFactory.CreateDatabase();
Model.UrlShortner UrlShort = new Model.UrlShortner();
// command
using (DbCommand cmd = DB.GetStoredProcCommand("dbo.Urls_Save"))
{
// parameters
DB.AddInParameter(cmd, "@Url", DbType.String, Url);
DB.AddInParameter(cmd, "@CreatedBy", DbType.String, CreatedBy);
// execute query and get results
using (DataTable DT = new DataTable())
{
using (IDataReader IDR = DB.ExecuteReader(cmd))
{
if (IDR != null)
{
DT.Load(IDR);
}
}
// convert to business object
if (DT.Rows.Count > 0)
{
UrlShort = DT.Rows[0].ToObject<Model.UrlShortner>();
}
}
}
// return value
return UrlShort;
}
}
}
It might seem like overkill for this example, but I'm using some extension methods to make the data layer much more generic. For example, instead of having to do manual column mapping to map the data from the DataTable to the model object I am able to use
ToObject and
ToList to do that generically. You wouldn't believe how much time generic extension methods like this will save you over the course of your project.
Extensions.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Data;
using System.ComponentModel;
namespace Sandbox.Utilities
{
public static class Extensions
{
private static Dictionary<Type, IList<PropertyInfo>> typeDictionary = new Dictionary<Type, IList<PropertyInfo>>();
public static IList<PropertyInfo> GetPropertiesForType<T>()
{
// variables
var type = typeof(T);
// get types
if (!typeDictionary.ContainsKey(typeof(T)))
{
typeDictionary.Add(type, type.GetProperties().ToList());
}
// return
return typeDictionary[type];
}
public static T ToObject<T>(this DataRow row) where T : new()
{
// variables
IList<PropertyInfo> properties = GetPropertiesForType<T>();
// return
return CreateItemFromRow<T>(row, properties);
}
public static IList<T> ToList<T>(this DataTable table) where T : new()
{
// variables
IList<T> result = new List<T>();
// foreach
foreach (DataRow row in table.Rows)
{
result.Add(row.ToObject<T>());
}
// return
return result;
}
private static T CreateItemFromRow<T>(DataRow row, IList<PropertyInfo> properties) where T : new()
{
// variables
T item = new T();
// foreach
foreach (var property in properties)
{
// make sure a column exists in the table with this property name
if (row.Table.Columns.Contains(property.Name))
{
// get the value from the current data row
object value = row[property.Name];
// set property accordingly
if (value != null & value != DBNull.Value)
{
SetProperty<T>(item, property.Name, value);
}
}
}
// return
return item;
}
public static string GetProperty<T>(this T obj, string Property)
{
// reflection
PropertyInfo propertyInfo = obj.GetType().GetProperty(Property, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
object property = null;
// make sure property is valid
if (propertyInfo != null)
{
property = propertyInfo.GetValue(obj, null);
}
// return value
if (property != null)
{
return property.ToString();
}
else
{
return string.Empty;
}
}
public static T SetProperty<T>(this T obj, string Property, object Value)
{
// reflection
PropertyInfo prop = obj.GetType().GetProperty(Property, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
// trim strings
if (Value.GetType() == typeof(string))
{
Value = Value.ToString().Trim();
}
// make sure property is valid
if (prop != null && prop.CanWrite)
{
prop.SetValue(obj, Value, null);
}
// return
return obj;
}
public static bool HasValue(this string Value)
{
return !Value.IsBlank();
}
public static bool IsBlank(this string Value)
{
bool ReturnValue = true;
if (Value != null)
{
ReturnValue = Value.Trim().Length == 0;
}
return ReturnValue;
}
}
}
Now to round this all out we need to create a shortened url and use it. To create a shortened url you simply call the
Save method from the data layer:
// create shorterned url
using (Data.Urls DB = new Data.Urls())
{
using (Model.UrlShortner MyUrl = DB.Save(Url: "http://www.google.com", CreatedBy: "Testing"))
{
// MyUrl now has a reference to your shortened url
}
}
I typically would use a shortened url like this if I was going to include some kind of custom url for a user, for example resetting their password. The url may include an encrypted token and could look pretty ugly, so having a way to provide a nice clean url makes that much more appealing for the email.
Example Url:
http://dev.sandbox.com/forgot-password.aspx?q=urXsbqy9knIpqqOddkFICXTAspidUXCmPS2vJsLqkVanePU%2bGgxLahBGGl1EA%2f%2fXyXxGT4EX6kcqYSvy8BTpib6eEB61Q5WxNcNfEjk1OYx4HGyvO5oKs34JZ%2f1p9jMuAvVpkbkKpBBjYD2UiotIiYob%2baSzHmxRUuUJYkRepd6kSosnXOXssKVQJ%2bWbQQkYfNZWt2OUe9nGw1UOBd1aeO3O9JyEqSiMkYoIF1blW3f9MUx461LSnB9FL2Q4Vbn%2bDGyK0kEHPQVaz5fhpSXIPrfN3Q%2flRtjyqWDXrRnx2%2bE%3e
Example Shortened Url:
http://dev.sandbox.com/url/?Z4cgw
So now we can create the shortened url and know what it looks like, all that's left to do is allow your web application to actually recognize the shortened url and redirect to the actual url.
Start off by creating a new folder in your web project. I called mine
url, which is why in the example shortened url above I used
/url. This could literally be any folder name you wanted, but something short makes sense.
After you've got the folder created, create a
Default.aspx page in that folder, with the following:
Default.aspx
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="Sandbox.Web.url.Default" %>
<!-- see code behind -->
Default.aspx.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using Sandbox.Utilities;
namespace Sandbox.Web.url
{
public partial class Default : System.Web.UI.Page
{
protected void Page_Load(object sender, EventArgs e)
{
if (!Page.IsPostBack)
{
ProcessUrl();
}
}
private void ProcessUrl()
{
// variables
string Key = Request.QueryString.ToString();
string Url = string.Empty;
// check database for url
if (Key.HasValue())
{
using (Data.Urls DB = new Data.Urls())
{
using (Model.UrlShortner x = DB.Get(Key: Key))
{
Url = x.Url;
}
}
}
// redirect
if (Url.HasValue())
{
Response.Redirect(Url);
Response.End();
}
else
{
Response.Redirect("~/home.aspx");
Response.End();
}
}
}
}
This page simply takes the querystring value and checks to see if there is a matching record in the
Urls table with the specified
Key. If found, it redirects to that url. If not, it falls back to redirecting the user to some default/home page.
This is just one way to build a URL shortner. You can strip out the requirement for the extension methods or change it to use your own style of model/data classes, but the overall concept doesn't change:
- Database table for the url data
- Views/Functions/Procs for creating/accessing the url data
- Model object for representing the url data
- Data layer class for calling the stored procedures and returning the model object
- Folder for processing the shortened url and redirecting appropriately
Matt Pavey is a Microsoft Certified software developer who specializes in ASP.Net, VB.Net, C#, AJAX, LINQ, XML, XSL, Web Services, SQL, jQuery, and more. Follow on Twitter @matthewpavey