Category Archives: ASP.NET

ASP.NET Identity 2.1 do MVC projektu s vlastní implementací UserStore (přes repositories)

Tento post popisuje jeden z mých prvních pokusů s ASP.NET Identity, které jsem chtěl zapojit do N-tier projektu s odděleným doménovým modelem, datovou vrstvou nad EntityFrameworkem, repositories, unit-of-work, atp. Webový projekt zde je abstrahován od Entity Frameworku a nemá přístup k DbContextu (nemá referenci na EntityFramework.dll).

Rychle se tedy ukázalo, že se musím vydat cestou vlastní implementace UserStore (resp. custom storage provider), který by na rozdíl od Microsoftí implementace (z balíčku Microsoft.AspNet.Identity.EntityFramework) neměl dependency na DbContext, ale na Repositories.

Relevantní články

Před zahájením implementace doporučuji zorientovat se trochu v problematice:

Pozor také, že ASP.NET Identity existuje v několika verzích a starší články se odkazují na starší verze. Verze 1.0 byla pouze základem, např, s omezením na string-klíče na IUser a IRole. Verze 2.x je aktuální podoba pro ASP.NET 4.x a mimo IUser<TKey>, IRole<TKey> interface s volbou typu klíče doplňuje i podporu zamykání účtů a spoustu dalších drobností. ASP.NET Identity 3.x je ve vývoji pro ASP.NET 5. Svět ASP.NET je nyní hodně dynamický a „kvalita“ dokumentace tomu bohužel odpovídá.

Výchozí struktura projektů

DomainClasses

  • POCO entity
  • nereferencuje skoro nic (rozhodně ne EntityFramework)
  • sem budeme umisťovat naše entity User, Role, …

DataLayer

  • persistence doménových tříd pomocí EntityFrameworku
  • mapování entit na DB, DbContext
  • referencuje DomainClasses, Entity Framework
  • sem musíme našim entitám User, Role, … nastavit mapování na DB

Repositories

  • prostě repositories :-) a související (UnitOfWork, atp.
  • referencuje DomainClasses, DataLayer
  • sem doplníme UserRepository, RoleRepository, …

Services

  • aplikační logika, WCF služby, fasády pro UI, …
  • referencuje DomainClasses, Repositories
  • sem budeme umisťovat naši implementaci UserStore s napojením na Repositories

Web

  • klasický ASP.NET MVC projekt
  • referencuje DomainClassses, Services, Repositories, nikoliv však EntityFramework
  • …a tady „to“ celé pomocí UserManageru použijeme
  • a propojíme s OWIN authentizací (cookies-based)

1. Instalace NuGet balíčků

Nejprve musíme do naší solution nainstalovat příslušné NuGet balíčky.

Microsoft.AspNet.Identity.Core

Tento NuGet balíček je základem celého ASP.NET Identity a musíme ho dostat skoro všude (bohužel!)

  • do DomainClasses – budeme potřebovat interfaces IUser, IRole. Dalo by se obejít bez těchto interfaces, ale museli bychom potom buď dodefinovat interfaces na nějakém potomkovi vytvořeném v Services pro účely propojení s ASP.NET Identity, nebo bychom museli pro ASP.NET Identity vytvořit třídy nové a na doménové třídy (entity) je mapovat (např. pomocí AutoMapperu)
  • dp DataLayer, Repositories – pořád kvůli interfaces IUser, IRole
  • do Services – budeme potřebovat pro IUserStore, IRoleStore, IUserRoleStore, …
  • do Web – sem hlavně ;-)

Balíček Microsoft.AspNet.Identity.Core naštěstí nemá žádné další závislosti, takže nám do DomainClasses, DAL, Repositories a Services nedotáhne žádný další „bordel“.

Microsoft.AspNet.Identity.Owin

OWIN implementace ASP.NET Identity do projektu Web.

Přitáhne sebou spoustu dependencies jako OWIN a další související balíčky Microsoft.Owin.Security.

Microsoft.Owin.Host.SystemWeb

Aby nám OWIN fungoval na „legacy“ System.Web. :-))´

2. Vytvoření entit User, Role + persistence do DB v DAL + Repositories

Do projektu DomainClasses vytvoříme entity User a Role.

User implementuje rozhraní IUser<TKey>, které naštěstí předepisuje jen TKey Id a string UserName. Další properties jsou na nás, později sem přidáme třeba něco na uložení hashovaného hesla, atp.

public interface IUser<out TKey>
{
    TKey Id { get; }
    string UserName { get; set; }
}

User pak může vypadat třeba takto:

public class User : IUser<int>
{
  [Required]
  public int Id { get; set; }

  [Required]
  public string UserName { get; set; }

  public ICollection<Role> Roles { get; set; }
  
  public string PasswordHash { get; set; }
}

Role implementuje obdobné rozhraní IRole<TKey>, které předpisuje properties TKey Id a string Name.

public interface IRole<out TKey>
{
    TKey Id { get; }
    string Name { get; set; }
}

Role pak může vypadat takto:

public class Role : IRole<int>
{
  [Required]
  public int Id { get; set; }

  [Required]
  public string Name { get; set; }
}

Dle vlastního způsobu implementace přidáme do projektu DataLayer persistenci nových entit (mapování EntityTypeConfiguration<>, IDbSet<> do DbContext, atp.). Stejně tak svým způsobem vytvoříme příslušné Repositories. Ani jedno není předmětem tohoto článku – pěkně v kostce viz třeba PluralSight: Entity Framework in the Enterprise (Julie Lerman).

3. Implementace UserStore, popř. RoleStore

UserStore opět není nic jiného, než naše třída, která implementuje jedno nebo více rozhraní, podle toho, co všechno od ASP.NET Identity očekáváme. Jedná se víceméně o CRUD operace, popř. několik dalších „storage“ operací, jako např. vyhledávání dle UserName, atp., vše pěkně připraveno pro async:

public interface IUserStore<TUser, in TKey> : IDisposable where TUser: class, IUser<TKey>
{
    Task CreateAsync(TUser user);
    Task DeleteAsync(TUser user);
    Task<TUser> FindByIdAsync(TKey userId);
    Task<TUser> FindByNameAsync(string userName);
    Task UpdateAsync(TUser user);
}

UserStore následně předhazujeme jako dependency do UserManageru, což je středobod ASP.NET Identity. UserManager obsahuje většinu aplikační logiky a kdo zná starší MembershipProvidery, tak si jej může představit jako novou podobu třídy Membership.

Nemá smysl, abych zde vypisoval celý UserStore, stačí malá ochutnávka, ostatní metody jsou implementovány stejně, zpravidla jako prosté volání metody příslušné Repository, nebo něco málo nad tím. Pro ukázku uvádím podobu volání vůči Repository, která nemá async podporu. Pokud máte async Repository, potom si jistě dokážete představit, že by kód byl ještě jednodušší.

public class UserStore : IUserStore<User, int>
{
  private readonly IUserRepository userRepository;
  private readonly IRoleRepository roleRepository;

  public UserStore(IUserRepository userRepository, IRoleRepository roleRepository)
  {
    this.userRepository = userRepository;
    this.roleRepository = roleRepository;
  }

  /// <summary>
  /// Insert a new user
  /// </summary>
  public Task CreateAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    
    userRepository.AddNew(user);

    return Task.FromResult<object>(null);
  }

  

  /// <summary>
  /// Finds a user
  /// </summary>
  public Task<User> FindByIdAsync(int userId)
  {
    return Task.FromResult(userRepository.GetByID(userId));
  }

  /// <summary>
  /// Find a user by name
  /// </summary>
  public Task<User> FindByNameAsync(string userName)
  {
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(userName));

    return Task.FromResult(userRepository.GetByUserName(userName));
  }
  
  ...

}

Pro úplnost uvádím, že UserStore zde počítá s dependency-injection repositories přes constructor. Custom implementace zde může být jiná.

4. Nastavení OWIN autentizace ve Startup.cs

Dostáváme se pomalu k projektu Web a použití ASP.NET Identity v něm. Samotné ASP.,NET Identity authentizaci neimplementuje, stejně tak jako nebyla implementována v MembershipProviderech. ASP.NET Identity poskytuje pouze určité služby pro obsluhu uživatelů, jejich členství v rolích, ukládání hesel, vazeb na externí authentizační služby ála Google/Facebook/Twitter, atp.

Samotný authentizační mechanizmus použijeme z OWIN, konkrétně Microsoft.Owin.Security.Cookies, což je něco jako nástupce FormsAuthentication a jeho FormAuthenticationTicketu v cookie.

Do webového projektu tedy přidáme novou položku „OWIN Startup class“ (pokud ji tam už nemáme), je to klasická item-template ve Visual Studiu 2013 (tuším od Update 2 nebo 3).

[assembly: OwinStartup(typeof(Havit.HealthGuard.Web.Startup))]

namespace Havit.HealthGuard.Web
{
	public class Startup
	{
		public void Configuration(IAppBuilder app)
		{
			app.UseCookieAuthentication(new CookieAuthenticationOptions()
				{
					AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
					LoginPath = new PathString("/authentication/login")
				});
		}
	}
}

5. Nastavení autorizace stránek

Aby celé naše snažení mělo nějaký smysl, musíme samozřejmě nejenom zajistit autentizaci (ověření identity), ale i autorizaci (nastavení přístupových práv). Jelikož zde předpokládám MVC projekt (WebForms viz jeden z článků odkazovaných výše), použijeme atribut [Authorize], pro začátek třeba v podobě globálního filtru (z Global.asax):

protected void Application_Start(object sender, EventArgs e)
{
	...
	FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
	...
}

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
	...
	filters.Add(new AuthorizeAttribute());
	...
}		 

…čímž omezíme přístup na všechny controllery pouze na přihlášené uživatele

6. Přihlašovací stránka

Dostáváme se k jádru věci – přihlašovací stránce. Nebudu zde popisovat zjevnou podobu View, ani jeho ViewModel, příslušná Action vypadá takto (userManager je zde přes dependency-injection z constructoru controlleru, ale můžete si samozřejmě jeho instanci postavit prostým constructorem):

[AllowAnonymous]
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Login(string returnUrl, AuthenticationLoginViewModel loginModel)
{
	if (ModelState.IsValid)
	{

		User user = userManager.Find(loginModel.UserName, loginModel.Password);
		if (user != null)
		{
			IAuthenticationManager authenticationManager = HttpContext.GetOwinContext().Authentication;
			authenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);

			ClaimsIdentity identity = userManager.CreateIdentity(user, DefaultAuthenticationTypes.ApplicationCookie);
			AuthenticationProperties props = new AuthenticationProperties();
			props.IsPersistent = loginModel.IsPersistent;
			authenticationManager.SignIn(props, identity);
					
			if (Url.IsLocalUrl(returnUrl))
			{
				return Redirect(returnUrl);
			}
			return this.RedirectToAction<HomeController>(c => c.Index());
		}
		ModelState.AddModelError("", "Invalid username or password.");
	}

	return View(loginModel);
}

Klíčovým řádkem je zde volání userManager.Find(string userName, string password), kterážto metoda vrací naší třídu User, resp. null, pokud přihlášení není úspěšné. Zbylé řádky jsou již jen tanečky kolem OWIN AuthenticationManageru, které nejsou předmětem tohoto článku.

Pokud nyní aplikaci pustíme a přihlašování zkusíme, pak budeme dostávat hlášku „Invalid username or password.“, pokud jsme do tabulky User ještě žádného uživatele nepřidali. Pokud ho tam přidáme, pak při vyplnění správného UserName dostaneme:

System.NotSupportedException: Store does not implement IUserPasswordStore<TUser>.

7. Rozšiřování UserStore

Dostali jsme se přesně do bodu, kde se ukazuje, jak je zamýšlen způsob implementace vlastních „storage-providerů“. ASP.NET Identity přichází se smečkou rozhraní a jednotlivé funkce UserManageru fungují/nefungují podle toho, kolik z těchto rozhraní UserStore implementuje.

Chceme-li použít lokální účty s lokálními hesly, musíme na náš UserStore přidat a implementovat rozhraní IUserPasswordStore.

public interface IUserPasswordStore<TUser, in TKey> : IUserStore<TUser, TKey>, IDisposable where TUser: class, IUser<TKey>
{
    Task<string> GetPasswordHashAsync(TUser user);
    Task<bool> HasPasswordAsync(TUser user);
    Task SetPasswordHashAsync(TUser user, string passwordHash);
}

…k rozhraní IUserStore<>, které již nemusíme explicitně uvádět, přidává několik metod, které pro práci s hesly slouží. Odbočkou se dostáváme k tomu, že UserStore je zde opravdu odpovědný pouze za perzistenci hashe, nikoliv jeho samotný výpočet. Ten svojí aplikační logiku zajišťuje UserManager, a pokud ho chceme ovlivnit, můžeme vyměnit UserManager.PasswordHasher za jinou/vlastní implementaci IPasswordHasher.

Pro UserStore se nabízejí následující interfaces:

V našem případě budeme chtít implementovat IUserPasswordStore pro ukládání hesel a IUserRoleStore pro přiřazování rolí uživatelům:

public class UserStore : IUserStore<User, int>, IUserPasswordStore<User, int>, IUserRoleStore<User, int>
{

  ...

  /// <summary>
  /// Set the user password hash
  /// </summary>
  public Task SetPasswordHashAsync(User user, string passwordHash)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    user.PasswordHash = passwordHash;
  
    return UpdateAsync(user);
  }

  /// <summary>
  /// Get the user password hash
  /// </summary>
  public Task<string> GetPasswordHashAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    return Task.FromResult(user.PasswordHash);
  }

  /// <summary>
  /// Returns true if a user has a password set
  /// </summary>
  public Task<bool> HasPasswordAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    
    return Task.FromResult(!String.IsNullOrWhiteSpace(user.PasswordHash));
  }

  /// <summary>
  /// Adds a user to a role
  /// </summary>
  public Task AddToRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    Role role = roleRepository.GetByName(roleName);
    if (role == null)
    {
      throw new InvalidOperationException(String.Format("Unrecognized role name: {0}", roleName));
    }

    if (!user.Roles.Contains(role))
    {
      user.Roles.Add(role);
    }

    return UpdateAsync(user);
  }

  /// <summary>
  /// Removes the role for the user
  /// </summary>
  public Task RemoveFromRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    user.Roles.Where(r => (r.Name == roleName)).ForEach(r => user.Roles.Remove(r));

    return UpdateAsync(user);
  }

  /// <summary>
  /// Returns the roles for this user
  /// </summary>
  public Task<IList<string>> GetRolesAsync(User user)
  {
    Contract.Requires<ArgumentNullException>(user != null);

    var roles = user.Roles.Select(r => r.Name).ToArray();

    return Task.FromResult<IList<string>>(roles);
  }

  /// <summary>
  /// Returns true if a user is in the role
  /// </summary>
  public Task<bool> IsInRoleAsync(User user, string roleName)
  {
    Contract.Requires<ArgumentNullException>(user != null);
    Contract.Requires<ArgumentException>(!String.IsNullOrWhiteSpace(roleName));

    bool result = user.Roles.Any(r => (r.Name == roleName));

    return Task.FromResult(result);
  }
}

RoleStore, RoleManager

Nakonec nutno dodat, že vedle IUserStore existuje ještě IRoleStore pro podporu perzistence rolí, tedy jejich samotných definicí, nikoliv jejich přiřazení uživatelům (což řeší výše uvedený IUserRolesStore).

K RoleStore samozřejmě existuje i RoleManager, který obsahuje aplikační logiku kolem rolí.

Závěr

Postavit vlastní UserStore pro ASP.NET Identity, který by používal Repositories, je jednoduché a velmi přímočaré. Jedinou vadou na kráse je absolutní nedostatek seriózní MSFT-dokumentace, na což si bohužel budeme muset zvykat.

ASP.NET MVC6 – Zápisky z MVP Summitu 2014 (non-NDA session)

Protože session o MVC6 na Microsoft MVP Summitu není pod NDA, vystavuji rovnou svoje neformální „raw“ zápisky.

Základní info

MVC6 = MVC + WebAPI + Web Pages (unifikace do jednoho společného API)

Build on ASP.NET 5

  • poběží na .NET core s jeho podporou side-by-side verzování
  • cross-platform
  • IIS/self-hosted

nativní Dependency Injection

  • unified abstraction

plně Async

command-line scaffolding

Enhanced Razor

  • @inject, @using, @inherits
  • async views
  • flush pointes
  • tag helpers

Get Started

project.json

přidat do sekce dependencies

  • „Microsoft.AspNet.Mvc“: „6.0.0-beta1“

Startup.cs

Configure(IApplicationBuilder app) – app.UseMvc(routes => routes.MapRoute(…))

ConfigureServices(IServiceCollection services)

services.AddMvc()

services.Configure<MvcOptions>(options => …)

  • options.Filters
  • options.InputFormatters
  • options.ModelViewBinders
  • options.OutputFormatters
  • options.ValueProviderFactories

Routes

  • default routes, constratints: „{area:exists}/{controller=HomeControler}/{action=Index}/{id?int?}“
  • all routes fall through to next route, if the action is not found
  • attribute routing enabled by default [Route], [HttpXxx], concat route pro controller a action, inherited
  • route tokes [controller], [action], …

Samples

Areas

[Area] atribut na controlleru, nemusí být ve složce Area

stává se pouze informací pro routing, kde se to musí potkat s {area}

Model binding, formatting

[FromBody[ – formatters

[FromQuery], [FromRoute], [FromForm], [FromHeader] – restrikce

[Produces] na action – restrikce media types výstupu

WebAPI

shim-package pro podporu legacy WebAPI projektů (např. konvence pojmenování action–method, atp.)

View Components !!

partial view s controller action

vrací View nebo formatted data

Async, DI

Dependency Injection

POCO controller bez dědění Controller třídy

[Activate] atribut k property-based injection, např [Activate] public ActionContext ActionContext

service filters: [ServiceFilter(typeof(MyFilter)]

type filters: [TypeFilter(typeof(MyFilter)]

Application Model, conventions

described through ActionDescriptorCollection

tweak conventions (ála EF)

  • IApplicationModelConvention
  • IControllerModelConvention

CS1647: An expression is too long or complex to compile (workaround)

Na jednom z projektů jsme po úpravě ASPX stránky s enormním objemem html kódu narazili na zajímavou chybu zobrazenou v kompilátorem: CS1647: An expression is too long or complex to compile. Velmi pozoruhodný je též popis chyby na MSDN a doporučené řešení.

Jako funkční workaround zafungovalo doporučení z blogu henrywrites a sice vložit do ASPX stránky serverový komentář, například

<%
// workaround pro CS1647: An expression is too long or complex to compile
%>

Copy–Paste a nechtěné mezery ve formulářích

Ve formulářovém poli chceme po uživateli e-mailovou adresu nebo třeba nový login name – jaké je jeho rozčarování, když po vložení adresy pomocí CTRL-V (zkopírované např. z Excelu), aplikace hlásí
nevalidní údaj.
Na vině jsou nechtěné mezery připojené za (a někdy i před) adresu.
Nechtěné mezery nám pomůže odstranit funkce aktivovaná událostí input -> na oninput řetězec trimujeme.
Provedeme to pouze, pokud se otrimovaný a původní řetězec liší (tj. je co trimovat) – pokud bychom to dělali bez této podmínky, tak vždy, když by uživatel ručně vepsal nějaký znak, skočil by mu kurzor v políčku na konec pole (i tehdy, pokud by něco editoval uprostřed textu).

if (formField.value != formField.value.trim())
{
    formField.value = formField.value.trim();
}

Pro ASP.NET si můžeme vytvořit takovýto skin:

<asp:TextBox SkinID="TrimOnInput" oninput="if (this.value!=this.value.trim()) {this.value=this.value.trim();};" runat="server" /> 

Funkce se nehodí pro pole, která mají obsahovat mezeru mezi slovy (např. „Jan Novák“) – i když takové celé jméno lze do pole vložit pomocí CTRL-C, zcela určitě nebude možné jméno pohodlně napsat – pole se totiž chová tak, že na stisk mezerníku za slovem nereaguje.

HTTP Redirect nastavovaný z IIS Manageru umí zavařit

Dneska ráno nás v práci uvítal critical ticket v HelpDesku, jednomu ze zákazníků „nešel web a všechno, co po přihlášení zkusil, vedlo na homepage“. Hned se nám spojilo, že to je web, na který kolega nastavoval přesměrování HTTP requestů na HTTPS a zkoušel si různé způsoby, jak to udělat.

Jeden z testů, které udělal, byl přes <httpRedirect> (HTTP Redirection). Pak od něj ale upustil a redirect nastavený přes IIS Manager zase vypnul.

Souhra okolností chtěla, aby toto zapnutí a vypnutí způsobilo poměrně velký problém.

Po IIS Manageru zůstalo v hlavním ~/web.configu webu toto:

<system.webServer>
		<httpRedirect enabled="false" destination="https://chester.xerox.cz" httpResponseStatus="Permanent" />
</system.webServer>

To by samo o sobě nevadilo. Peklo však nastalo v okamžiku, kdy se to potkalo s web.config soubory v podsložkách, v nichž byla různá specifická přesměrování od vývojářů:

<system.webServer>
		<httpRedirect enabled="true" exactDestination="true">
			<add wildcard="/Old-URL.aspx" destination="New-URL.aspx"/>
			...
		</httpRedirect>
</system.webServer>

Když se to celé sečetlo, tak ve všech takových podsložkách se reaktivoval disablovaný redirect z rootového web.configu a veškeré requesty na resources v dané složce přesměrovával.

…další důvod proč nemám rád, když IIS Manager modifikuje web.config. Hlavním je ten, že při nasazování chci web.config přepisovat vždy celý novou verzí a jakékoliv production-specific volby z něj mít vyextrahovány třeba pomocí atributu configSource.

WatiN: Could not load file or assembly Interop.SHDocVw

Pokud by vás WatiN po instalaci z NuGetu obšťastnil hláškou ve stylu

Unhandled Exception: System.IO.FileLoadException: Could not load file or assembl y ‚Interop.SHDocVw, Version=1.1.0.0, Culture=neutral, PublicKeyToken=db7cfd3acb5 ad44e‘ or one of its dependencies. The located assembly’s manifest definition do es not match the assembly reference. (Exception from HRESULT: 0x80131040) File name: ‚Interop.SHDocVw, Version=1.1.0.0, Culture=neutral, PublicKeyToken=db

…pak je potřeba na referenci Interop.SHDocVw nastavit v Properties volbu „Embed Interop Types“ na false:

imageimage

Jinak si zkouším hrát s WatiN-em. Máte s tím někdo zkušenosti? Výhody/nevýhody proti Visual Studio Coded UI Test (mimo licenčních)?

IIS Express: Managed Pipeline Mode – Classic

Pokud je Vaše aplikace psána pro Classic Managed Pipeline Mode, potom při standardním spuštění nad IIS Expressem dostanete krásnou chybku:

HTTP Error 500.22 – Internal Server Error

An ASP.NET setting has been detected that does not apply in Integrated managed pipeline mode.

image

Mě to potkalo u přechodu z VS2012 (kde jsem aplikaci pouštěl nad vestavěným Cassini) na Visual Studio 2013, kde je již jenom IIS Express. Přepínač je lehce ukryt, najdete ho v Properties projektu webové aplikace:

image

Optimalizace webových aplikací ASP.NET – záznam, slide a dema [MS Fest 2013]

Slide (jeden) a dema z mé dnešní přednášky pro MS Fest 2013 v Brně:

Z přednášky se pořizoval záznam, který najdete na našem HAVIT YouTube Channel:

25 Secrets for Faster ASP.NET – PDF kniha zdarma

imageUžitečná 38-stránková knížka s 25 performance tipy pro ASP.NET je zdarma ke stažení u Red Gate.

Dá se to prolistovat za pár minut a určitě je to dobrá inspirace.

…ne se vším se ztotožňuji, ale dva tipy jsou tentokrát od mě. ;-))

ASP.NET WebForms Scaffolding – Slides a dema [TechEd Praha 2013]

Slides a dema z mé přednášky na TechEd DevCon Praha 2013:

Videozáznam z této přednášky nebyl pořizován.