Should this turn out to be useful for anyone, I'll gladly turn it into a community wiki thing.
I have some slow pages in an MVC3 app, and since little of the execution time seemed to happen in my code, I wanted to see if I could find out more about what took so long. Not that I succeeded, but I gained a little more wisdom along the way.
There is nothing here that isn't obvious to anyone with some MVC experience. Basically, I created my own ActionFilterAttribute that looks like this:
public class ProfilerAttribute : ActionFilterAttribute
{
IDisposable actionStep = null;
IDisposable resultStep = null;
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
actionStep = MiniProfiler.Current.Step("OnActionExecuting " + ResultDescriptor(filterContext));
base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
if (actionStep != null)
{
actionStep.Dispose();
actionStep = null;
}
base.OnActionExecuted(filterContext);
}
public override void OnResultExecuting(ResultExecutingContext filterContext)
{
resultStep = MiniProfiler.Current.Step("OnResultExecuting " + ResultDescriptor(filterContext));
base.OnResultExecuting(filterContext);
}
public override void OnResultExecuted(ResultExecutedContext filterContext)
{
if (resultStep != null)
{
resultStep.Dispose();
resultStep = null;
}
base.OnResultExecuted(filterContext);
}
private string ResultDescriptor(ActionExecutingContext filterContext)
{
return filterContext.ActionDescriptor.ControllerDescriptor.ControllerName + "." + filterContext.ActionDescriptor.ActionName;
}
private string ResultDescriptor(ResultExecutingContext filterContext)
{
var values = filterContext.RouteData.Values;
return String.Format("{0}.{1}", values["controller"], values["action"]);
}
This seems to work well, and in my case I have learned that most of the time is actually spent in the ResultExecuting part of life, not inside my actions.
However, I have some questions about this approach.
1) Is this a request-safe way of doing things? I am guessing no, since the actionfilter is created only once, in the RegisterGlobalFilters() method in Global.asax.cs. If two requests appear at once, actionStep and resultStep will be worthless. Is this true? If so, can someone who knows more than me contribute a clever way to handle this? Works for me during local machine profiling, but probably not so much deployed on a server with multiple people making requests at the same time.
2) Is there any way to get more insight into the result-executing process? Or should I just accept that rendering the view etc. takes the time it takes? In my own app I ensure that all database access is finished before my action method is over (using NHibernate Profiler in my case), and I like to keep my views slim and simple; Any kind of insight into what slows the rendering down could still be useful, though. I guess using the Mini Profiler in my model objects would show up here, if any slow code on my part was executed here.
3) The ResultDescriptor methods are probably evil and poisonous. They've worked for me in my tests, but would probably need to be replaced by something more robust. I just went with the first versions that gave me something halfway useful.
Any other comments to this would also be very welcome, even if they are "This is a bad idea, go die alone".
This looks like a cool idea. I believe that it's NOT a request safe way of doing things.
You could link it to HttpContext.Items like this
HttpContext.Items.Add("actionstep", actionStep);
HttpContext.Items.Add("resultstep", resultStep);
And then retrieve it in similar fashion
actionStep = HttpContext.Items["actionstep"];
resultStep = HttpContext.Items["resultstep"];
Obviously putting in your own checks for nulls and so forth.
The HttpContext is different for each user/request.
The thing to remember about HttpContext.Current.Session.SessionID which I sometimes forget it that it is the SessionId of the current HTTP request (i.e. it changes each time you hit F5 or otherwise make a new request). The other important thing to remember is that, whilst at any on time, all HttpContext.Current.Session.SessionID values are necessarily unique (i.e. one for each user, or request), they can be reused, so dno't think of them as GUIDs which are only used once each.
There is already an action filter attribute in the MiniProfiler assembly that does the profiling for the actions. It is in the StackExchange.Profiling.MVCHelpers namespace and it's called ProfilingActionFilter. You can extend it to also profile your views.
It uses the same approach as described by #Dommer but instead of storing the IDisposable directly, it stores a Stack in the HttpContext.Current.Items. You can do the same for the views.
Here is the code for the action profiling:
/// <summary>
/// This filter can be applied globally to hook up automatic action profiling
///
/// </summary>
public class ProfilingActionFilter : ActionFilterAttribute
{
private const string stackKey = "ProfilingActionFilterStack";
/// <summary>
/// Happens before the action starts running
///
/// </summary>
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
if (MiniProfiler.Current != null)
{
Stack<IDisposable> stack = HttpContext.Current.Items[(object) "ProfilingActionFilterStack"] as Stack<IDisposable>;
if (stack == null)
{
stack = new Stack<IDisposable>();
HttpContext.Current.Items[(object) "ProfilingActionFilterStack"] = (object) stack;
}
MiniProfiler current = MiniProfiler.Current;
if (current != null)
{
RouteValueDictionary dataTokens = filterContext.RouteData.DataTokens;
string str1 = !dataTokens.ContainsKey("area") || string.IsNullOrEmpty(dataTokens["area"].ToString()) ? "" : (string) dataTokens["area"] + (object) ".";
string str2 = Enumerable.Last<string>((IEnumerable<string>) filterContext.Controller.ToString().Split(new char[1] { '.' })) + ".";
string actionName = filterContext.ActionDescriptor.ActionName;
stack.Push(MiniProfilerExtensions.Step(current, "Controller: " + str1 + str2 + actionName, ProfileLevel.Info));
}
}
base.OnActionExecuting(filterContext);
}
/// <summary>
/// Happens after the action executes
///
/// </summary>
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
base.OnActionExecuted(filterContext);
Stack<IDisposable> stack = HttpContext.Current.Items[(object) "ProfilingActionFilterStack"] as Stack<IDisposable>;
if (stack == null || stack.Count <= 0) return;
stack.Pop().Dispose();
}
}
Hope this help.
You can just wrap ExecuteCore method on Controller. :)
Related
what is the best way to capture page views by person without slowing down performance on the site. I see that stackoverflow show page views all over the place. Are they doing an insert into a db everytime i click on a page?
In asp.net-mvc, Is there any recommended way to track page view per user (my site has a login screen) so i can review which pages people are going to and how often
First off.. if what you really care about is how are customers using my site then you most likely want to look into Google Analytics or a similar service.
But if you want a quick and dirty page view record and you are using ASP.Net MVC 3 then as Chris Fulstow mentioned you're going to want to use a mix of global action filters and caching. Here is an example.
PageViewAttribute.cs
public class PageViewAttribute : ActionFilterAttribute
{
private static readonly TimeSpan pageViewDumpToDatabaseTimeSpan = new TimeSpan(0, 0, 10);
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var calledMethod = string.Format("{0} -> {1}",
filterContext.ActionDescriptor.ControllerDescriptor.ControllerName,
filterContext.ActionDescriptor.ActionName);
var cacheKey = string.Format("PV-{0}", calledMethod);
var cachedResult = HttpRuntime.Cache[cacheKey];
if(cachedResult == null)
{
HttpRuntime.Cache.Insert(cacheKey, new PageViewValue(), null, DateTime.Now.Add(pageViewDumpToDatabaseTimeSpan) , Cache.NoSlidingExpiration, CacheItemPriority.Default,
onRemove);
}
else
{
var currentValue = (PageViewValue) cachedResult;
currentValue.Value++;
}
}
private static void onRemove(string key, object value, CacheItemRemovedReason reason)
{
if (!key.StartsWith("PV-"))
{
return;
}
// write out the value to the database
}
// Used to get around weird cache behavior with value types
public class PageViewValue
{
public PageViewValue()
{
Value = 1;
}
public int Value { get; set; }
}
}
And in your Global.asax.cs
public class MvcApplication : HttpApplication
{
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
filters.Add(new PageViewAttribute());
}
}
For pre-ASP.Net MVC 3 ONLY you are going to have to apply the same attribute manually to all of your actions.
[PageView]
public ActionResult CallOne()
{
}
[PageView]
public ActionResult CallTwo()
{
}
The best way would probably be a global action filter that intercepts requests to all actions on all controllers, then increments a counter in the database for the current user and page. To save hitting the database too hard, you could cache these values and invalidate them every few minutes, depending on how much traffic you're dealing with.
We use the open source Piwik: http://piwik.org/, which is setup on it's own server. One line of Javascript in the _Layout page makes a call to Piwik after the page has loaded (put the JS at the end) and does not affect page load performance at all.
In addition to just counts, you'll get a ton of info about where your users are coming from, browser, screen resolutions, installed plugins. Plus you can track conversions and use the same tool to track marketing campaigns, etc.
<soapbox>
I cannot think of a situation where you'd be better off implementing this in MVC or in your web app in general. This stuff simply does not belong in your web app and is a meta-concern that should be separated out. This approach has enabled us to track analytics for all of our apps (32 of them: mvc 2/3, webforms, php...) in a unified manner.
If you really don't want to use another tool for this purpose, I would recommend tapping into your IIS log and getting your stats from there. Again, to get any real decision making power out of it, you'll need to put a good analyzer on it. I recommend Splunk: http://www.splunk.com/
</soapbox>
I wanted to post an updated version of Shane's answer for those who are interested. Some things to consider:
You have to set the action attribute up as a service when decorating your
methods using syntax like the following :
[ServiceFilter(typeof(PageViewAttribute))]
As far as I can tell, HttpRuntime.Cache.Insert isn't a thing in .NET Core, so I used a simple implementation of IMemoryCache (You may need to add this line to your startup.cs in order to use the interface):
services.AddMemoryCache();
Because we are injecting IMemoryCache into a class that is not a controller, we need to register our attribute as a service in startup.cs, like so:
services.AddScoped<[PageViewAttribute]>(); - without brackets!
Whatever object you return when creating a cacheKey will be assigned to the 'value' parameter of the OnRemove method.
Below is the code.
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var controllerActionDescriptor = filterContext.ActionDescriptor as ControllerActionDescriptor;
var arguments = filterContext.ActionArguments;
ActionId = arguments["id"].ToString();
var calledMethod = string.Format("{0} -> {1}",
controllerActionDescriptor.ControllerName,
controllerActionDescriptor.ActionName);
var cacheKey = string.Format("PV-{0}", calledMethod);
var cachedResult = _memoryCache.Get(cacheKey);
if (cachedResult == null)
{
//Get cacheKey if found, if not create cache key with following settings
_memoryCache.GetOrCreate(cacheKey, cacheKey =>
{
cacheKey.AbsoluteExpirationRelativeToNow
= pageViewDumpToDatabaseTimeSpan;
cacheKey.SetValue(1);
cacheKey.RegisterPostEvictionCallback(onRemove);
return cacheKey.Value;
});
}
else
{
_memoryCache.Get(cacheKey);
}
}
//Called when Memory entry is removed
private void onRemove(object key, object value, EvictionReason reason, object state)
{
if (!key.ToString().StartsWith("PV-"))
{
return;
}
// write out the value to the database
SaveToDataBase(key.ToString(), (int)value);
}
As a point of reference, this was done for a .NET Core 5 MVC App.
Regards.
We're using ASP.Net Web API to generate a feed and it includes the ability to do paging.
myfeed.com/afeed?page=2
My boss says "let's also allow users to use 'paged', because that's what WP uses." In addition, we're also using pageIndex in some of our older feeds. So what I'd like to do is accept all three.
myfeed.com/afeed?page=2
myfeed.com/afeed?paged=2
myfeed.com/afeed?pageIndex=2
I'd like to do is be able to write a clean Web API method, such as
public Foo Get(int page = 1)
{
//do some stuff
return foo;
}
without cluttering the method with page 'plumbing'. So I tried creating an ActionFilter
public override void OnActionExecuting(HttpActionContext actionContext)
{
object pageParam = new object(); //query["page"]
if (pageParam == null)
{
var altPageParam = GetPageParamUsingAlternateParams(actionContext);
if (altPageParam != null){}
//SetPageParam here
}
base.OnActionExecuting(actionContext);
}
private object GetPageParamUsingAlternateParams(HttpActionContext actionContext)
{
object result = new object();
object pageIndexParam = new object(); //Query["pageIndex"]
object pagedParam = new object(); ////Query["paged"]
if (pagedParam != null)
result = pagedParam;
else if (pageIndexParam != null)
result = pageIndexParam;
return result;
}
I didn't finish. As I was looking for the best way to get the query params, I stumbled into a big mistake!
OnActionExecuting is executed after int page = 1. Sure, I could override it in an ActionFilter, but that would lead to confusion down the road. I really want to be able to do a simple flow through the URI query parameters that goes from
page -> paged -> pageIndex -> default value in method
I have found a lot of articles on custom binding to a an object. Also, I found articles about "parameter binding", however those dealt with FromUri and FromBody. I didn't find anything that I felt had a direct parallel to what I'm facing.
You could achieve what you want by defining 3 different GET method with parameters matched with the query segment of the Url like the code snippet below:
public class ProductsController : ApiController
{
//Matched api/products?page=1
public IHttpActionResult Get(int page)
{
return GetPagedData(page);
}
//Matched api/products?paged=1
public IHttpActionResult GetPaged(int paged)
{
return GetPagedData(paged);
}
//Matched api/products?pagIndex=1
public IHttpActionResult GetPageIndex(int pageIndex)
{
return GetPagedData(pageIndex);
}
//Do the real paging here
private IHttpActionResult GetPagedData(int page =1)
{
return Ok("Data Pages");
}
}
Again there are multiple articles which says how to access data after redirect. but doesn't serves my purpose.
I am having errorcontroller which is having index action method and error index view.
If there is any error in the application it will caught in Application_Error event.
inside Application_Error event I had logged the error and redirected to Error Index page like this -
protected new void Application_Error(object sender, EventArgs e)
{
Exception error = Server.GetLastError();
log.error(error.Message);
HttpContext.Current.Response.Redirect("~/Error/Index");
}
Now in the error index view, I would like to display the error message. What should I do in Application_Error event which can be access by Error Index view?
Updated : I don't want to use Session as session object may not be available in Application_Error event. this is dependent on when the error occurred.
Approach - 1
As per my knowledge you can use TempData to store the posted data. It is like a DataReader Class, once read, Data will be lost. So that stored data in TempData will become null.
var Value = TempData["keyName"] //Once read, data will be lost
So to persist the data even after the data is read you can Alive it like below
var Value = TempData["keyName"];
TempData.Keep(); //Data will not be lost for all Keys
TempData.Keep("keyName"); //Data will not be lost for this Key
TempData works in new Tabs/Windows also, like Session variable does.
You could use Session Variable also, Only major problem is that Session Variable are very heavy comparing with TempData. Finally you are able to keep the data across Controllers/Area also.
Approach - 2
This works for me. This is very easy and no need to consider any change in Web.Config or Register the Action Filter in Global.asax file.
ok. So, First I am creating a simple Action Filter. This will handle Ajax and Non Ajax requests.
public class MyCustomErrorAttribute : HandleErrorAttribute
{
public override void OnException(ExceptionContext filterContext)
{
filterContext.ExceptionHandled = true;
var debugModeMsg = filterContext.HttpContext.IsDebuggingEnabled
? filterContext.Exception.Message +
"\n" +
filterContext.Exception.StackTrace
: "Your error message";
//This is the case when you need to handle Ajax requests
if (filterContext.HttpContext.Request.IsAjaxRequest())
{
filterContext.Result = new JsonResult
{
JsonRequestBehavior = JsonRequestBehavior.AllowGet,
Data = new
{
error = true,
message = debugModeMsg
}
};
}
//This is the case when you handle Non Ajax request
else
{
var routeData = new RouteData();
routeData.Values["controller"] = "Error";
routeData.Values["action"] = "Error";
routeData.DataTokens["area"] = "app";
routeData.Values["exception"] = debugModeMsg;
IController errorsController = new ErrorController();
var exception = HttpContext.Current.Server.GetLastError();
var httpException = exception as HttpException;
if (httpException != null)
{
Response.StatusCode = httpException.GetHttpCode();
switch (System.Web.HttpContext.Current.Response.StatusCode)
{
case 504:
routeData.Values["action"] = "Http404";
break;
}
}
var rc = new RequestContext
(
new HttpContextWrapper(HttpContext.Current),
routeData
);
errorsController.Execute(rc);
}
base.OnException(filterContext);
}
}
Now you can implement this Action Filter on Controller as well as on the Action only.Example:
I am going little off topic. I thought this is bit important to explain.
If you pay attention to the above highlighted part. I have specified the order of the Action Filter. This basically describes the order of execution of Action Filter. This is a situation when you have multiple Action Filters implemented over Controller/Action Method
This picture just indicates that let's say you have two Action Filters. OnActionExecution will start to execute on Priority and OnActionExecuted will start from bottom to Top. That means in case of OnActionExecuted Action Filter having highest order will execute first and in case of OnActionExecuting Action Filter having lowest order will execute first. Example below.
public class Filter1 : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
//Execution will start here - 1
base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
//Execution will move here - 5
base.OnActionExecuted(filterContext);
}
}
public class Filter2 : ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
//Execution will move here - 2
base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext)
{
//Execution will move here - 4
base.OnActionExecuted(filterContext);
}
}
[HandleError]
public class HomeController : Controller
{
[Filter1(Order = 1)]
[Filter2(Order = 2)]
public ActionResult Index()
{
//Execution will move here - 3
ViewData["Message"] = "Welcome to ASP.NET MVC!";
return View();
}
}
You may already aware that there are different types of filters within MVC framework. They are listed below.
Authorization filters
Action filters
Response/Result filters
Exception filters
Within each filter, you can specify the Order property. This basically describes the order of execution of the Action Filters.
Use TempData for getting value.
Some feature about TempData
TempData is a dictionary object that is derived from TempDataDictionary class and stored in short lives session.
TempData is used to pass data from current request to subsequent request means incase of redirection.
It’s life is very short and lies only till the target view is fully loaded.
It’s required typecasting for complex data type and check for null values to avoid error.
It is used to store only one time messages like error messages, validation messages.
I'm facing a strange problem with ASP.NET MemoryCaching in a MVC 3 ASP.NET application.
Each time an action is executed, I check if its LoginInfo are actually stored in the MemoryCache (code has been simplified, but core is as follow):
[NonAction]
protected override void OnAuthorization(AuthorizationContext filterContext) {
Boolean autorizzato = false;
LoginInfo me = CacheUtils.GetLoginData(User.Identity.Name);
if (me == null)
{
me = LoginData.UserLogin(User.Identity.Name);
CacheUtils.SetLoginInfo(User.Identity.Name, me);
}
// Test if the object is really in the memory cache
if (CacheUtils.GetLoginData(User.Identity.Name) == null) {
throw new Exception("IMPOSSIBLE");
}
}
The GetLoginInfo is:
public static LoginInfo GetLoginData(String Username)
{
LoginInfo local = null;
ObjectCache cache = MemoryCache.Default;
if (cache.Contains(Username.ToUpper()))
{
local = (LoginInfo)cache.Get(Username.ToUpper());
}
else
{
log.Warn("User " + Username + " not found in cache");
}
return local;
}
The SetLoginInfo is:
public static void SetLoginInfo (String Username, LoginInfo Info)
{
ObjectCache cache = MemoryCache.Default;
if ((Username != null) && (Info != null))
{
if (cache.Contains(Username.ToUpper()))
{
cache.Remove(Username.ToUpper());
}
cache.Add(Username.ToUpper(), Info, new CacheItemPolicy());
}
else
{
log.Error("NotFound...");
}
}
The code is pretty straightforward, but sometimes (totally randomly), just after adding the LoginInfo to the MemoryCache, this results empty, the just added Object is not present, therefore I got the Exception.
I'm testing this both on Cassini and IIS 7, it seems not related to AppPool reusability (enabled in IIS 7), I've tested with several Caching policies, but cannot make it work
What Am I missing/Failing ?
PS: forgive me for my bad english
Looking at the code for MemoryCache using a decomplier there is the following private function
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs eventArgs)
{
if (!eventArgs.IsTerminating)
return;
this.Dispose();
}
There is an unhandled exception handler setup by every MemoryCache for the current domain Thread.GetDomain() so if there is ever any exception in your application that is not caught which may be common in a website it disposes the MemoryCache for ever and cannot be reused this is especially relevant for IIS apps as apposed to windows applications that just exit on unhanded exceptions.
The MemoryCache has limited size. For the Default instance, is't heuristic value (according to MSDN).
Have you tried to set Priority property on CacheItemPolicy instance to NotRemovable?
You can have race-condition because the Contains-Remove-Add sequence in SetLoginInfo is not atomic - try to use Set method instead.
Btw. you are working on web application so why not to use System.Web.Caching.Cache instead?
I believe you are running into a problem that Scott Hanselman identified as a .NET 4 bug. Please see here: MemoryCache Empty : Returns null after being set
I was just testing Output Caching in the RC build of ASP.NET MVC 3.
Somehow, it is not honoring the VaryByParam property (or rather, I am not sure I understand what is going on):
public ActionResult View(UserViewCommand command) {
Here, UserViewCommand has a property called slug which is used to look up a User from the database.
This is my OutputCache declaration:
[HttpGet, OutputCache(Duration = 2000, VaryByParam = "None")]
However, when I try and hit the Action method using different 'slug' values (by manupulating the URL), instead of serving wrong data (which I am trying to force by design), it is instead invoking the action method.
So for example (in order of invocation)
/user/view/abc -> Invokes action method with slug = abc
/user/view/abc -> Action method not invoked
/user/view/xyz -> Invokes action method again with slug = xyz! Was it not supposed to come out of the cache because VaryByParam = none?
Also, what is the recommended way of OutputCaching in such a situation? (example above)
Just wanted to add this information so that people searching are helped:
The OutputCache behavior has been changed to be 'as expected' in the latest release (ASP.NET MVC 3 RC 2):
http://weblogs.asp.net/scottgu/archive/2010/12/10/announcing-asp-net-mvc-3-release-candidate-2.aspx
Way to go ASP.NET MVC team (and Master Gu)! You all are awesome!
VaryByParam only works when the values of the url look like /user/view?slug=abc. The params must be a QueryString parameter and not part of the url like your above examples. The reason for this is most likely because Caching happens before any url mapping and that mapping isn't included in the cache.
Update
The following code will get you where you want to go. It doesn't take into account stuff like Authorized filters or anything but it will cache based on controller/action/ids but if you set ignore="slug" it will ignore that particular attribute
public class ActionOutputCacheAttribute : ActionFilterAttribute {
public ActionOutputCacheAttribute(int cacheDuration, string ignore) {
this.cacheDuration = cacheDuration;
this.ignore = ignore;
}
private int cacheDuration;
private string cacheKey;
private string ignore;
public override void OnActionExecuting(ActionExecutingContext filterContext) {
string url = filterContext.HttpContext.Request.Url.PathAndQuery;
this.cacheKey = ComputeCacheKey(filterContext);
if (filterContext.HttpContext.Cache[this.cacheKey] != null) {
//Setting the result prevents the action itself to be executed
filterContext.Result =
(ActionResult)filterContext.HttpContext.Cache[this.cacheKey];
}
base.OnActionExecuting(filterContext);
}
public override void OnActionExecuted(ActionExecutedContext filterContext) {
//Add the ActionResult to cache
filterContext.HttpContext.Cache.Add(this.cacheKey, filterContext.Result,null, DateTime.Now.AddSeconds(cacheDuration),
System.Web.Caching.Cache.NoSlidingExpiration, CacheItemPriority.Normal, null);
//Add a value in order to know the last time it was cached.
filterContext.Controller.ViewData["CachedStamp"] = DateTime.Now;
base.OnActionExecuted(filterContext);
}
private string ComputeCacheKey(ActionExecutingContext filterContext) {
var keyBuilder = new StringBuilder();
keyBuilder.Append(filterContext.ActionDescriptor.ControllerDescriptor.ControllerName);
keyBuilder.Append(filterContext.ActionDescriptor.ActionName);
foreach (var pair in filterContext.RouteData.Values) {
if (pair.Key != ignore)
keyBuilder.AppendFormat("rd{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
}
return keyBuilder.ToString();
}
}