Serenity 6.2.0 Release Notes (2022-10-10)
We'll publish these release notes for versions that deserve more explanation than short descriptions available in our change log.
Implemented Logging Support for SqlHelper Methods
In the past, we used an ILog
interface before transitioning to .NET Core and its dependency injection mechanism. The logger could be configured via web.config
or appsettings.json
files and could be redirected to a directory under the App_Data/Log
folder by default.
Since version 5.x, we've been utilizing .NET Core's ILoggerFactory and ILogger interfaces. These loggers are typically injected into target classes via constructor injection.
Unfortunately, our SqlHelper methods, which were previously responsible for logging information about executed queries, are static and have no direct means to obtain a logger instance through dependency injection. As a result, logging was not functioning correctly.
To address this issue, we introduced a logger that is passed to these extensions via the IDbConnection interface. While IDbConnection itself doesn't have a logger property, our connections are typically IWrappedConnection instances.
To implement this, we introduced a new interface called IHasLogger
:
namespace Serenity.Data
{
public interface IHasLogger
{
ILogger Logger { get; }
}
}
This interface has been implemented in the WrappedConnection
class as follows:
public class WrappedConnection : IDbConnection, IHasActualConnection, IHasCommandTimeout,
IHasCurrentTransaction, IHasDialect, IHasLogger, IHasOpenedOnce
{
// By default, logger is null, to prevent breaking existing users of the WrappedConnection constructor.
public WrappedConnection(IDbConnection actualConnection, ISqlDialect dialect, ILogger logger = null)
{
}
// ...
public ILogger Logger { get; }
}
These wrapped connection instances are created by ISqlConnections providers, with DefaultSqlConnections
being the default provider:
public class DefaultSqlConnections : ISqlConnections
{
// ...
public DefaultSqlConnections(IConnectionStrings connectionStrings, IConnectionProfiler profiler = null,
ILoggerFactory loggerFactory = null)
{
}
public virtual IDbConnection New(string connectionString, string providerName, ISqlDialect dialect)
{
// ...
if (profiler != null)
return new WrappedConnection(profiler.Profile(connection), dialect, loggerFactory.CreateLogger<ISqlConnections>());
return new WrappedConnection(connection, dialect, loggerFactory.CreateLogger<ISqlConnections>());
}
}
The default SQL connections provider obtains an ILoggerFactory instance through constructor injection and creates, as well as passes, an ILogger instance to the wrapped connection instances.
With this approach, static methods in SqlHelper
can now access a logger instance via their IDbConnection
arguments if they implement the IHasLogger
interface.
As part of this update, we also employ WrappedConnection
for various other purposes, such as obtaining a dialect from the connection, accessing the current transaction, and setting command timeouts for that connection, all through specialized interfaces like IHasCurrentTransaction
. This flexible approach allows other connection classes to implement these features if desired, avoiding the creation of hard dependencies on WrappedConnection
itself.
The default SQL connections provider obtains an ILoggerFactory
instance via constructor injection and creates, as well as passes, an ILogger
instance to the wrapped connection instances.
This approach enables static methods in SqlHelper
to acquire a logger instance through their IDbConnection
arguments, provided that they implement the IHasLogger
interface.
We employ WrappedConnection
for similar purposes, such as retrieving a dialect from the connection, accessing the current transaction, setting the command timeout for that connection, and more, all through specialized interfaces like IHasCurrentTransaction
. This way, another connection class can implement these interfaces if needed, allowing us to avoid creating a rigid dependency on WrappedConnection
.
Here's an example of the log output displayed in the console (Kestrel console or Visual Studio developer console):
info: Microsoft.Hosting.Lifetime[0]
Content root path: C:\Sandbox\StartSharp\StartSharp\StartSharp.Web
warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
Failed to determine the https port for redirect.
dbug: Serenity.Data.ISqlConnections[0]
SQL - ExecuteReader[20961011] - START
SELECT
T0.[Id] AS [Id],
T0.[LanguageName] AS [LanguageName],
T0.[LanguageId] AS [LanguageId]
FROM [Languages] T0
ORDER BY T0.[LanguageName]
dbug: Serenity.Data.ISqlConnections[0]
SQL - ExecuteReader[20961011] - END - 4 ms
We now have the ability to inspect the contents of all SQL commands executed through the Serenity entity system (or SqlHelper methods). This allows us to monitor when a command started and ended, as well as how much time it took to execute. The number [20961011] serves as a hash code for the DBCommand instance, which proves invaluable for matching START and END logs, particularly in scenarios where multiple threads are concurrently executing SQL statements.
To activate logging, please ensure that your default logging level in the appsettings.json file is set to Debug
or a higher level:
"Logging": {
"LogLevel": {
"Default": "Debug"
}
}
Alternatively, you can opt for debug-level logging exclusively for the Serenity.Data
namespace:
"Logging": {
"LogLevel": {
"Serenity.Data": "Debug"
}
}
We believe it's better to enable debug logging only in debug environments by default, and not in production. Therefore, you may consider adding an appsettings.Development.json
file if you don't have one already and override the logging level to Debug
only in that file.
Here's an example of appsettings.Development.json
created for StartSharp 6.2.0
:
// appsettings.Development.json
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Serenity.Data": "Debug"
}
}
}
And the base appsettings.json
file:
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
With this setup, the logging level defaults to Information
for environments other than Development
. In a Development
environment, typically used for debugging, the logging level becomes Debug
, but only for the Serenity.Data
namespace.
For more information on .NET Core logging, please refer to the official documentation.
Added Brotli Compression Support to Dynamic Scripts and Script Bundles
You might not be aware, but the scripts served by the DynamicScriptMiddleware
(/DynJS.axd), such as lookups and script bundles, are automatically compressed on the fly with GZIP compression (on-demand). This feature is enabled for content larger than 4096 bytes by default.
When the browser requests the URL and sends an Accept
header containing gzip
, which is the case for most current browsers, it receives the compressed version.
However, there's a more effective format called Brotli
, which results in smaller files, typically around 15%-25%
on average. Moreover, both the compression and decompression times are faster for brotli
when compared to GZIP. Decompression, in particular, is about 40%
faster.
While not all browsers support Brotli
yet, it has a coverage of approximately 96.39%
, according to https://caniuse.com/brotli, which can be considered quite high.
To enable Brotli
for DynamicScriptMiddleware
, you don't need to take any action other than updating your Serenity packages. If the browser supports it, the content will be compressed with brotli
by default, instead of GZIP.
We have conducted tests on our Serenity Demo Dashboard
by first loading it with Brotli
disabled:
Then, we enabled Brotli
:
This resulted in approximately a 107KB
reduction in size, a significant improvement, considering that it affects only the content served by Dynamic Script Middleware
and is applicable to files larger than 4K
. The script and CSS files in our demo are around 500K
compressed, compared to 607K
before enabling brotli
.
Converted JSON Local Text Files to Static Web Assets and Moved Them To Relevant Packages
Translations for Serenity itself are now distributed via the Serenity.Scripts
NuGet package. The source files can be found at https://github.com/serenity-is/Serenity/tree/master/src/Serenity.Scripts/wwwroot/texts
.
The Sergen restore command previously used to locate these files from the NuGet package and copy them to the YourProject.Web/wwwroot/Scripts/serenity/texts
folder.
In StartSharp and Serene's Startup.cs file, there was a code block responsible for loading them into the local text registry:
services.AddAllTexts(new[]
{
Path.Combine(env.WebRootPath, "Scripts", "serenity", "texts"),
Path.Combine(env.WebRootPath, "Scripts", "site", "texts"),
Path.Combine(env.ContentRootPath, "App_Data", "texts")
});
In the past, the process of loading local texts had some limitations. The first line was dedicated to loading Serenity's local texts, while the next two lines were responsible for loading application-specific texts. This setup had a few drawbacks.
Firstly, Serenity texts could only be loaded from the disk. If you didn't run the dotnet sergen restore
command after updating Serenity.Scripts
, you might end up with an older set of texts in that folder.
Secondly, this method only worked with Serenity.Scripts
and other feature packages, such as Serenity.Demo.Northwind
, Serenity.Extensions
, etc., couldn't ship their own set of local text files.
To make matters worse, this approach was not unit testable and required physical disk access.
We have been utilizing the Static Web Assets
feature of the Razor SDK
for our script and CSS files for some time. We've now decided to apply the same functionality to local text files. As a result, we are now shipping JSON local text files
as static web assets
. There is no need for dotnet sergen restore
anymore for these files.
After updating Serenity, you may remove the Scripts/serenity/texts
folder. You only need to make a simple change in your Startup.cs
:
services.AddBaseTexts(env.WebRootFileProvider)
.AddJsonTexts(env.WebRootFileProvider, "Scripts/site/texts")
.AddJsonTexts(env.ContentRootFileProvider, "App_Data/texts");
The AddBaseTexts
call in the first line accomplishes what AddAllTexts
did in the previous version. Additionally, it can load all JSON text files stored as static web assets
from referenced packages via an ITypeSource
implementation. This includes Serenity.Scripts
in addition to any other assembly with localized texts, like Serenity.Demo.Northwind
, Serenity.Pro.Meeting
, and so on. All these assemblies should have a Serenity.ComponentModel.JsonLocalTextAssets
attribute.
If you want to include your JSON files for your own feature projects, you will need to add a target like the following (preferably in a Directory.Build.targets
file):
<Target Name="AddJsonLocalTextAssetsAttribute" BeforeTargets="CoreGenerateAssemblyInfo" Condition="Exists('wwwroot\texts')">
<ItemGroup>
<AssemblyAttribute Include="Serenity.ComponentModel.JsonLocalTextAssets">
<_Parameter1>$(StaticWebAssetBasePath)/texts</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Target>
These changes will enhance the efficiency and flexibility of loading local texts within Serenity.
During compilation, this process creates an attribute in your project that identifies the path to local text files. Ensure that you place your .json files under wwwroot\texts
.
Please note that this attribute is useful for feature projects like Serenity.Pro.Meeting
but not applicable to projects like YourProject.Web
.
Transition to Modular TypeScript in StartSharp
Originally, we employed namespace-style TypeScript, as it was the preferred approach around nine years ago.
It's worth noting that even TypeScript itself is written using namespaces (refer to https://github.com/microsoft/TypeScript/blob/main/src/compiler/core.ts) and still relies on them. There's an ongoing effort to transition to modules.
Before TypeScript, we used Script# followed by Saltaralle, both of which were C# to JavaScript compilers. At the time, namespaces felt like a natural choice to ease the transition to TypeScript and enhance interoperability with existing Saltaralle code.
Given that most of our users are C# developers, namespaces felt more intuitive to us and facilitated the mapping between C# and TypeScript classes.
However, it has become apparent that namespaces are gradually becoming second-class citizens in both the TypeScript and JavaScript realms. Most code samples and libraries available today are authored using ECMAScript modules and distributed as NPM packages.
In the past, we intentionally refrained from using options like WebPack, as they were overly complex for our purposes and introduced build performance issues.
Another limitation of namespaces becomes evident when you aim to have distinct, isolated parts in your web application, such as frontend and backend, while selectively sharing some code between them. Due to the difficulties in determining class dependencies, namespaces don't offer an easy solution for such scenarios.
Starting with Serenity 6.1, we introduced modular TypeScript support. We leverage the esbuild
tool, known for its speed in tasks like building, bundling, and minification:
We will soon publish a blog post explaining the reasons behind our transition to modular TypeScript and providing guidance on how to develop in this style.
In StartSharp 6.2.0, we have completed the conversion of all remaining pages previously found under the Namespaces
folder into modular TypeScript files, now residing under the Modules
folder. Here's a comparison between the old "Change Password" panel and its new modular version:
Old Code (Change Password Panel):
namespace StartSharp.Membership {
@Serenity.Decorators.registerClass()
export class ChangePasswordPanel extends Serenity.PropertyPanel<ChangePasswordRequest, any> {
protected getFormKey() { return ChangePasswordForm.formKey; }
private form: ChangePasswordForm;
constructor(container: JQuery) {
super(container);
this.form = new ChangePasswordForm(this.idPrefix);
//...
New Modular Version:
import { ChangePasswordForm, ChangePasswordRequest } from "@/ServerTypes/Membership";
import { Texts } from "@/ServerTypes/Texts";
import { PropertyPanel } from "@serenity-is/corelib";
import { format, information, resolveUrl, serviceCall, text } from "@serenity-is/corelib/q";
$(function () {
new ChangePasswordPanel($('#ChangePasswordPanel'));
});
class ChangePasswordPanel extends PropertyPanel<ChangePasswordRequest, any> {
protected getFormKey() { return ChangePasswordForm.formKey; }
private form: ChangePasswordForm;
constructor(container: JQuery) {
super(container);
this.form = new ChangePasswordForm(this.idPrefix);
The code transition involves moving the panel from a namespace-based structure to a modular TypeScript file, which allows for improved organization and maintainability. This change is part of our ongoing efforts to enhance the structure and architecture of StartSharp.
No Namespace Code in StartSharp.Web
Project
The StartSharp.Web
project no longer contains any namespaced code. We are in the process of converting our feature packages to the modular style, which will be the next step in this transition.
Renamed ScriptInitialization.ts to ScriptInit.ts [StartSharp]
In the context of StartSharp
, we've renamed the modular TypeScript version of ScriptInitialization.ts
to ScriptInit.ts
. This update helps avoid confusion with the classic namespace version. The script is loaded into both modular and namespace files from _LayoutHead.cshtml
:
<script type="module" src="~/esm/Modules/Common/ScriptInit.js"></script>
Unlike the namespace version, there isn't a way to define a global script file that runs in all modular TypeScript pages. This feature was previously possible using the inject
capability of esbuild
. However, since we still have non-modular, namespaced TypeScript code in some feature packages, we'll revert to this approach after completing the conversion to modular style.
Removed Imports/ServerTypings Folder
The Imports/ServerTypings
folder was exclusively used for namespace typings and had no relevance in modular TypeScript. With the complete conversion of classic namespace code to modular TypeScript in StartSharp, new projects created with StartSharp 6.2.0 and beyond will not include an Imports
folder. Instead, you will find a Modules/ServerTypes
folder containing a similar set of files, but in the modular TypeScript style.
Enabled keepNames
Option of ESBuild in tsbuild.js
The Serenity Widget system appends CSS class names to HTML elements based on the widget's class name. However, during the minification process, esbuild
removes these class names. As a result, any CSS rules relying on class names of widgets are no longer applied. For instance:
.s-SomeDialog {
background-color: red;
}
To address this issue, we have enabled the keepNames
option in tsbuild.js
. This option ensures that the CSS class names are retained during the minification process, allowing your CSS rules to function as expected.
Addressed Widget Class Naming Issue in esbuild
Minification
In certain cases, elements with CSS classes, such as s-SomeDialog
, were affected by esbuild
during the minification process. As esbuild
minified widget class names, the element would inherit the new class name, such as s-b
, instead of the expected s-SomeDialog
.
To resolve this issue, we've introduced the keepNames: true
option in the tsbuild.js
file, where we invoke esbuild
. It ensures that CSS class names are retained during the minification process:
await esbuild.build({
//...
entryPoints: entryPoints,
format: 'esm',
keepNames: true,
//...
}
If your project was created before version 6.2.0
, it is recommended to add this change to your tsbuild.js
file.
Fixed Clean Plugin in tsbuild.js
We've implemented a Clean
plugin for esbuild
to remove old files under the wwwroot\esm
directory after a successful build. This prevents the accumulation of unused files in the folder.
A typo in the plugin was preventing old files from being deleted correctly. To address this, make the following change in the plugin code:
From:
if (!outputs || !existsSync(build.initialOptions.outDir))
To:
if (!outputs || !existsSync(build.initialOptions.outdir))
This modification ensures the correct deletion of old files in the specified directory.