From 03549a181521009baf6d353a98f4cb8804602cdc Mon Sep 17 00:00:00 2001 From: 杨宇千 Date: Tue, 20 Aug 2019 23:41:36 +0800 Subject: Use etag for cache. --- Timeline/Controllers/UserAvatarController.cs | 23 +++--- Timeline/Entities/UserAvatar.cs | 4 + Timeline/Models/Http/Common.cs | 6 ++ Timeline/Services/DatabaseCorruptedException.cs | 15 ++++ Timeline/Services/ETagGenerator.cs | 33 +++++++++ Timeline/Services/UserAvatarService.cs | 97 +++++++++++++++++++++---- 6 files changed, 153 insertions(+), 25 deletions(-) create mode 100644 Timeline/Services/DatabaseCorruptedException.cs create mode 100644 Timeline/Services/ETagGenerator.cs (limited to 'Timeline') diff --git a/Timeline/Controllers/UserAvatarController.cs b/Timeline/Controllers/UserAvatarController.cs index ffadcb86..964c9b98 100644 --- a/Timeline/Controllers/UserAvatarController.cs +++ b/Timeline/Controllers/UserAvatarController.cs @@ -2,7 +2,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using Microsoft.Net.Http.Headers; using System; +using System.Linq; using System.Threading.Tasks; using Timeline.Authenticate; using Timeline.Filters; @@ -61,22 +63,23 @@ namespace Timeline.Controllers [Authorize] public async Task Get([FromRoute] string username) { - const string IfModifiedSinceHeaderKey = "If-Modified-Since"; + const string IfNonMatchHeaderKey = "If-None-Match"; try { - var avatarInfo = await _service.GetAvatar(username); - var avatar = avatarInfo.Avatar; - if (Request.Headers.TryGetValue(IfModifiedSinceHeaderKey, out var value)) + var eTag = new EntityTagHeaderValue($"\"{await _service.GetAvatarETag(username)}\""); + + if (Request.Headers.TryGetValue(IfNonMatchHeaderKey, out var value)) { - var t = DateTime.Parse(value); - if (t > avatarInfo.LastModified) - { - Response.Headers.Add(IfModifiedSinceHeaderKey, avatarInfo.LastModified.ToString("r")); + if (!EntityTagHeaderValue.TryParseStrictList(value, out var eTagList)) + return BadRequest(CommonResponse.BadIfNonMatch()); + + if (eTagList.First(e => e.Equals(eTag)) != null) return StatusCode(StatusCodes.Status304NotModified); - } } - return File(avatar.Data, avatar.Type, new DateTimeOffset(avatarInfo.LastModified), null); + var avatarInfo = await _service.GetAvatar(username); + var avatar = avatarInfo.Avatar; + return File(avatar.Data, avatar.Type, new DateTimeOffset(avatarInfo.LastModified), eTag); } catch (UserNotExistException e) { diff --git a/Timeline/Entities/UserAvatar.cs b/Timeline/Entities/UserAvatar.cs index b941445d..d549aea5 100644 --- a/Timeline/Entities/UserAvatar.cs +++ b/Timeline/Entities/UserAvatar.cs @@ -16,6 +16,9 @@ namespace Timeline.Entities [Column("type")] public string Type { get; set; } + [Column("etag"), MaxLength(30)] + public string ETag { get; set; } + [Column("last_modified"), Required] public DateTime LastModified { get; set; } @@ -28,6 +31,7 @@ namespace Timeline.Entities Id = 0, Data = null, Type = null, + ETag = null, LastModified = lastModified }; } diff --git a/Timeline/Models/Http/Common.cs b/Timeline/Models/Http/Common.cs index 50f6836e..a72f187c 100644 --- a/Timeline/Models/Http/Common.cs +++ b/Timeline/Models/Http/Common.cs @@ -13,6 +13,7 @@ namespace Timeline.Models.Http public const int Header_Missing_ContentType = -111; public const int Header_Missing_ContentLength = -112; public const int Header_Zero_ContentLength = -113; + public const int Header_BadFormat_IfNonMatch = -114; } public static CommonResponse InvalidModel(string message) @@ -35,6 +36,11 @@ namespace Timeline.Models.Http return new CommonResponse(ErrorCodes.Header_Zero_ContentLength, "Header Content-Length must not be 0."); } + public static CommonResponse BadIfNonMatch() + { + return new CommonResponse(ErrorCodes.Header_BadFormat_IfNonMatch, "Header If-Non-Match is of bad format."); + } + public CommonResponse() { diff --git a/Timeline/Services/DatabaseCorruptedException.cs b/Timeline/Services/DatabaseCorruptedException.cs new file mode 100644 index 00000000..9988e0ad --- /dev/null +++ b/Timeline/Services/DatabaseCorruptedException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Timeline.Services +{ + [Serializable] + public class DatabaseCorruptedException : Exception + { + public DatabaseCorruptedException() { } + public DatabaseCorruptedException(string message) : base(message) { } + public DatabaseCorruptedException(string message, Exception inner) : base(message, inner) { } + protected DatabaseCorruptedException( + System.Runtime.Serialization.SerializationInfo info, + System.Runtime.Serialization.StreamingContext context) : base(info, context) { } + } +} diff --git a/Timeline/Services/ETagGenerator.cs b/Timeline/Services/ETagGenerator.cs new file mode 100644 index 00000000..e2abebdc --- /dev/null +++ b/Timeline/Services/ETagGenerator.cs @@ -0,0 +1,33 @@ +using System; +using System.Security.Cryptography; + +namespace Timeline.Services +{ + public interface IETagGenerator + { + string Generate(byte[] source); + } + + public class ETagGenerator : IETagGenerator, IDisposable + { + private readonly SHA1 _sha1; + + public ETagGenerator() + { + _sha1 = SHA1.Create(); + } + + public string Generate(byte[] source) + { + if (source == null || source.Length == 0) + throw new ArgumentException("Source is null or empty.", nameof(source)); + + return Convert.ToBase64String(_sha1.ComputeHash(source)); + } + + public void Dispose() + { + _sha1.Dispose(); + } + } +} diff --git a/Timeline/Services/UserAvatarService.cs b/Timeline/Services/UserAvatarService.cs index a83b8a52..7b1f405c 100644 --- a/Timeline/Services/UserAvatarService.cs +++ b/Timeline/Services/UserAvatarService.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats; @@ -64,6 +65,12 @@ namespace Timeline.Services /// public interface IDefaultUserAvatarProvider { + /// + /// Get the etag of default avatar. + /// + /// + Task GetDefaultAvatarETag(); + /// /// Get the default avatar. /// @@ -82,6 +89,15 @@ namespace Timeline.Services public interface IUserAvatarService { + /// + /// Get the etag of a user's avatar. + /// + /// The username of the user to get avatar etag of. + /// The etag. + /// Thrown if is null or empty. + /// Thrown if the user does not exist. + Task GetAvatarETag(string username); + /// /// Get avatar of a user. If the user has no avatar, a default one is returned. /// @@ -107,22 +123,46 @@ namespace Timeline.Services { private readonly IHostingEnvironment _environment; - public DefaultUserAvatarProvider(IHostingEnvironment environment) + private readonly IETagGenerator _eTagGenerator; + + private byte[] _cacheData; + private DateTime _cacheLastModified; + private string _cacheETag; + + public DefaultUserAvatarProvider(IHostingEnvironment environment, IETagGenerator eTagGenerator) { _environment = environment; + _eTagGenerator = eTagGenerator; } - public async Task GetDefaultAvatar() + private async Task CheckAndInit() { + if (_cacheData != null) + return; + var path = Path.Combine(_environment.ContentRootPath, "default-avatar.png"); + _cacheData = await File.ReadAllBytesAsync(path); + _cacheLastModified = File.GetLastWriteTime(path); + _cacheETag = _eTagGenerator.Generate(_cacheData); + } + + public async Task GetDefaultAvatarETag() + { + await CheckAndInit(); + return _cacheETag; + } + + public async Task GetDefaultAvatar() + { + await CheckAndInit(); return new AvatarInfo { Avatar = new Avatar { Type = "image/png", - Data = await File.ReadAllBytesAsync(path) + Data = _cacheData }, - LastModified = File.GetLastWriteTime(path) + LastModified = _cacheLastModified }; } } @@ -161,12 +201,36 @@ namespace Timeline.Services private readonly IDefaultUserAvatarProvider _defaultUserAvatarProvider; private readonly IUserAvatarValidator _avatarValidator; - public UserAvatarService(ILogger logger, DatabaseContext database, IDefaultUserAvatarProvider defaultUserAvatarProvider, IUserAvatarValidator avatarValidator) + private readonly IETagGenerator _eTagGenerator; + + public UserAvatarService( + ILogger logger, + DatabaseContext database, + IDefaultUserAvatarProvider defaultUserAvatarProvider, + IUserAvatarValidator avatarValidator, + IETagGenerator eTagGenerator) { _logger = logger; _database = database; _defaultUserAvatarProvider = defaultUserAvatarProvider; _avatarValidator = avatarValidator; + _eTagGenerator = eTagGenerator; + } + + public async Task GetAvatarETag(string username) + { + if (string.IsNullOrEmpty(username)) + throw new ArgumentException("Username is null or empty.", nameof(username)); + + var userId = await _database.Users.Where(u => u.Name == username).Select(u => u.Id).SingleOrDefaultAsync(); + if (userId == 0) + throw new UserNotExistException(username); + + var eTag = (await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.ETag }).SingleAsync()).ETag; + if (eTag == null) + return await _defaultUserAvatarProvider.GetDefaultAvatarETag(); + else + return eTag; } public async Task GetAvatar(string username) @@ -174,16 +238,17 @@ namespace Timeline.Services if (string.IsNullOrEmpty(username)) throw new ArgumentException("Username is null or empty.", nameof(username)); - var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); - if (user == null) + var userId = await _database.Users.Where(u => u.Name == username).Select(u => u.Id).SingleOrDefaultAsync(); + if (userId == 0) throw new UserNotExistException(username); - await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); - var avatar = user.Avatar; + var avatar = await _database.UserAvatars.Where(a => a.UserId == userId).Select(a => new { a.Type, a.Data, a.LastModified }).SingleAsync(); - if ((avatar.Type == null) == (avatar.Data == null)) + if ((avatar.Type == null) != (avatar.Data == null)) + { _logger.LogCritical("Database corupted! One of type and data of a avatar is null but the other is not."); - // TODO: Throw an exception to indicate this. + throw new DatabaseCorruptedException(); + } if (avatar.Data == null) { @@ -218,12 +283,11 @@ namespace Timeline.Services throw new ArgumentException("Data of avatar is null.", nameof(avatar)); } - var user = await _database.Users.Where(u => u.Name == username).SingleOrDefaultAsync(); - if (user == null) + var userId = await _database.Users.Where(u => u.Name == username).Select(u => u.Id).SingleOrDefaultAsync(); + if (userId == 0) throw new UserNotExistException(username); - await _database.Entry(user).Reference(u => u.Avatar).LoadAsync(); - var avatarEntity = user.Avatar; + var avatarEntity = await _database.UserAvatars.Where(a => a.UserId == userId).SingleAsync(); if (avatar == null) { @@ -233,6 +297,7 @@ namespace Timeline.Services { avatarEntity.Data = null; avatarEntity.Type = null; + avatarEntity.ETag = null; avatarEntity.LastModified = DateTime.Now; await _database.SaveChangesAsync(); _logger.LogInformation("Updated an entry in user_avatars."); @@ -243,6 +308,7 @@ namespace Timeline.Services await _avatarValidator.Validate(avatar); avatarEntity.Type = avatar.Type; avatarEntity.Data = avatar.Data; + avatarEntity.ETag = _eTagGenerator.Generate(avatar.Data); avatarEntity.LastModified = DateTime.Now; await _database.SaveChangesAsync(); _logger.LogInformation("Updated an entry in user_avatars."); @@ -254,6 +320,7 @@ namespace Timeline.Services { public static void AddUserAvatarService(this IServiceCollection services) { + services.TryAddTransient(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); -- cgit v1.2.3