Designbest Core Sviluppo
Goal
Realizzare una versione agile e super performante di Designbest usando le ultime tecnologie Microsoft disponibili (.NET Core 6 .NET Core 7).
Page Speed Insights
- Riduzione del TTFB (Time To The First Byte)
- Immagini di dimensioni adeguate al dispositivo (Imagekit.io)
- Specificare width e height per ogni immagine
- Specificare alt per ogni immagine
- Minificazione delle dipendenze da terze parti (animazioni fatte in linguaggio nativo piuttosto che utilizzo di plugin esterni)
- Raggruppamento (bundling) delle risorse CSS e Javascript
- Minificazione delle risorse raggruppate
- Ottimizzazione del tempo di Javascript (tramite promise e processi async).
- Inclusione delle risorse Javascript strettamente necessarie alla pagina (e non librerie complete)
- Inclusione delle risorse CSS strettamente necessarie alla pagina.
- Precaricamento della Largest Contentful Paint per ogni pagina
- Immagini e iframe lazy (caricamento pigro)
- Riduzione elementi in pagina tramite caricamenti asincroni su richiesta (modali, librerie)
- Compressione dell'HTML generato
- Compressione delle risorse statiche
- manifest con info per creare un PWA
- Velocità media caricamento pagina costantemente < 3s
SEO
- Semantica della pagina (h1, h2, section, main content, aside content, header, footer, nav).
- Gestione di meta tag seo description, title
- Href Lang (link seo che indicano la stessa pagina in lingua differente)
- Logiche di redirect applicative (con listing vuoti => redirect al listing superiore, negozio chiuso => redirect a listing di quella città)
- Structured Data (validate da Google)
- Breadcrumbs
- Loghi (nome organizzazione e canali social)
- Snippet di prodotto
- Snippet di recensioni (stelline)
- Casella di ricerca
- Caroselli (lista di prodotti)
- Attività locali (negozi)
Lato Applicativo
- Riduzione del TTFB (Time To The First Byte) tramite controllo completo sulle query e le stored procedure
- Logiche di redirect applicative (con listing vuoti => redirect al listing superiore, negozio chiuso => redirect a listing di quella città)
- Gestione geolocalizzazione (database MaxMind aggiornato)
- Algoritmi di ordinamento prodotti
- Gestione Ad manager (DFP secondo logiche custom di targeting ambiente/zona/manu)
- Gestione logiche 4Dem (ultimo prodotto visto, data, spedizione flusso newsletter)
- Bottoni Sponsor che appaiono nell'ambiente preciso
Database
Accorpamento database:
- Designbest
- Trovaprodotti
Tabelle
- Language
- LocalizedProperty
- Category
- ManufacturerContractType
- Manufacturer
- Product
- ProductCulture
- ProductBargain
- ProductBargainCulture
- ProductProperty
- ProductPropertyValue
- Mapping_Category_ProductProperty
- Mapping_Product_ProductPropertyValue
- ShopPointProperty
- ShopPointPropertyValue
- Mapping_ShopPoint_ShopPointPropertyValue
- ShopPointSponsor
- Agent
- ShopNetStatus
- ShopNet
- ShopPointVisibilityType
- ShopPoint
- Trovaprodotti
- TrovaprodottiCulture
- Region
- Province
- City
- Picture
- Customer
- Mapping_Customer
- User
- UserUnregistered
- Wishlist
- Mapping_Wishlist_Product
- Selection
- Mapping_Selection_Product
- ProductThumbs
- History_Email
Popolamento Region/Province/City
Attualmente abbiamo importato i dati sporchi così come sono con la seguente query
INSERT INTO DesignbestCore.dbo.Region
SELECT Region.ID,
CASE
WHEN Region.CountryID = 3 THEN 1
WHEN Region.CountryID = 2 THEN 3
WHEN Region.CountryID = 4 THEN 4
WHEN Region.CountryID = 1000 THEN 2
END AS LanguageID,
Region.Coordinates,
Region.LocalizerCode,
RegionCulture.Region AS [Name],
LOWER(RegionCulture.SEORegion) AS SeoName
FROM Region
INNER JOIN RegionCulture ON Region.ID = RegionCulture.RegionID
INSERT INTO DesignbestCore.dbo.Province
SELECT Province.ID,
CASE
WHEN Province.CountryID = 3 THEN 1
WHEN Province.CountryID = 2 THEN 3
WHEN Province.CountryID = 4 THEN 4
WHEN Province.CountryID = 1000 THEN 2
END AS LocalizationID,
Province.Coordinates,
Province.ProvinceCode,
Province.RegionID,
ProvinceCulture.Province AS Name,
LOWER(ProvinceCulture.SEOProvince) AS SeoName
FROM Province
INNER JOIN ProvinceCulture ON Province.ID = ProvinceCulture.ProvinceID
INNER JOIN Region ON Province.RegionID = Region.ID
INSERT INTO DesignbestCore.dbo.City
SELECT City.ID,
CASE
WHEN City.CountryID = 3 THEN 1
WHEN City.CountryID = 2 THEN 3
WHEN City.CountryID = 4 THEN 4
WHEN City.CountryID = 1000 THEN 2
END AS LocalizationID,
City.ProvinceID,
City.City AS [Name],
LOWER(City.SeoCity) AS SeoName
FROM City
INNER JOIN Province ON Province.ID = City.ProvinceID
INNER JOIN Region ON Province.RegionID = Region.ID
Custom Properties
L'idea è quella di creare da una semplicissima struttura di tabelle
- ProductProperty
- ProductPropertyValue
- Mapping_Category_ProductProperty
- Mapping_Product_ProductPropertyValue
l'assegnazione di qualsiasi proprietà (stile, finitura, colori, decori, formati, proprietà di tipologia, designer, campi custom)
Per ognuna di queste custom property è possibile specificare
- Nome
- Nome SEO
- Valore
- Immagine (opzionale, gestita da
Pictures)
Stesso concetto su tabelle separate per gli ShopPoint
- ShopPointProperty
- ShopPointPropertyValue
- Mapping_ShopPoint_ShopPointPropertyValue
assegnazione delle proprietà come gli ShopPointServices
Per quanto riguarda gli ShopPointServices, purtroppo abbiamo ereditato una logica per la quale, se esiste il valore PropertyValue nel mapping, allora il negozio possiede quella Property. Di conseguenza, se il negozio possiede una certa Property e non specifica nessun suo valore, è necessario inserire un PropertyValue vuoto.
Schifezze
Controllare i valori delle custom properties perché il database è pieno di duplicati assegnati a prodotti diversi.
Ad esempio la ProductProperty Caratteristiche con SeoName Caratteristiche - Sedie c'è due volte con ID differenti.
Per rimediare si potrebbero assegnare tutti i prodotti ad un unico di questi ID e cancellare l'altro doppio.
Questa roba fa casino nel count dei prodotti nel telecomando
SELECT [Name],[SeoName], COUNT(SeoName) AS Duplic
FROM ProductPropertyValue
GROUP BY [Name], SeoName
ORDER BY Duplic DESC, [Name] ASC
ProductThumbs
Questa tabella purtroppo è necessaria in quanto i listing prodotti di una tipologia sono troppo lenti (tempo esecuz > 1s).
Una singola riga rappresenta un ProductThumbanil comprensivo di tutti i dati necessari a creare l'anteprima quadrata, titolo, ambiente, tipo manu e dati per creare i link.
Sarà tenuta aggiornata in tempo reale dalla backoffice (no task schedulati).
Tabelle escluse
Di seguito le tabelle che non riteniamo più utili:
ShopManufacturerExceptionShopContentExtra
Shop
ShopNet Casi Particolari
Attenzione che ogni SHOPNET deve avere una mail univoca altrimenti non viene caricato il Vendor su DOKAN (Wordpress vuole che le mail siano univoche per ogni utente).
Tutti i negozi ora appartengono obbligatoriamente ad una ShopNet Queste ShopNet in particolare:
- ShopNet Trovaprodotti Orfani Tutti i Trovaprodotti che non avevano una ShopNet. Hanno
Piva = '000000000000'. Ne viene creata una per ognuno. - ShopNet Anagrafiche - Contiene tutti gli ShopPoint di tipo != 0 che sono anagrafiche. Un padre per molti figli. Ha
Piva = '000000000003'. - ShopNet Estero - Tutti gli ShopPoint Esteri. Hanno
Piva = '000000000004'. Ne viene creata una per ognuno.
ShopFulltextAdditions
Gestione della ricerca full-text dei negozi che aggiunge ambienti, tipologie e produttori alle chiavi di ricerca del singolo negozio.
Sarà necessario reimplementare tutto il passaggio con il task notturno che genera le righe?
Pictures
La gestione delle immagini sarà basata su quella di Nop.
| Picture | ||
|---|---|---|
| ID | int | ID della picture |
| EntityID | int | ID dell'entità |
| EntityKeyGroup | nvarchar(100) | Nome della Tabella di riferimento |
| EntityKey | nvarchar(100) | Formato dell'immagine desiderato |
| EntityValue | nvarchar(255) | Nome dell'immagine |
| DisplayOrder | int | Ordinamento |
Tutte le immagini saranno in un unico folder
product-<EntityID>-<ID>.jpg
bargain-<EntityID>-<ID>.jpg
manufacturer-<EntityID>-<ID>.jpg
shop-<EntityID>-<ID>.jpgQuesta soluzione è meno leggibile dall'umano ma molto più elastica nel caso in cui ci siano diversi cambiamenti di ordine, master/alternative (evita di dover rinominare l'immagine su disco).
Esempio Volendo trovare tutte le immagini relative ad uno shop si potrebbe usare la seguente query:
SELECT *
FROM Picture
WHERE EntityID = 15314 AND KeyGroup = 'Shop'
Avendo come risultato qualcosa di simile.
bla bla bla ....
Convenzioni
Per quanto riguarda i campi EntityGroup e EntityKey ecco cosa proponiamo
- ShopPoint
- cover
- logo
- gallery
- Product
- gallery
- square
- Manufacturer
- logo
- logo-square
- cover
- sponsored (bottone sponsor in HP)
- gallery
- Category
- ambient
Le selezioni
Utilizziamo il concetto di selezione per definire gruppi di prodotti (oppure negozi ecc) al fine di poterli richiamare in quei moduli che attualmente hanno una logica circostanziale spesso gestiti con xml.
In questo modo possiamo creare N selezioni tramite il backoffice da usare nei casi più svariati.
Importazione dati
Eseguire nell'ordine
- Reset_All_Tables (database
DesignbestCore) - WM_CategorySynchCore
- WM_ProductSynchCore
- WM_ShopNetSynchCore
- WM_ProductPropertySynchCore
- FullTextSearch, eseguire i comandi del paragrafo #Negozi
- WM_PicturesSynchCore (verificare aggiornamento path di XML BrandChannel su
D:\Temp) su BIANCANEVE - WM_UsersCore
- Cancellare tutte le immagini nella cartella
pictures - Immagini occasioni - Eseguire script Python tramite
D:\Programs\MigrazioneDB_DBCore\DesignbestBargainMigration.batsu ARIEL (~ 3 min). - Immagini cover shop - Eseguire script Python
D:\Programs\MigrazioneDB_DBCore\DesignbestShopCoverAltMigration.pysu ARIEL - Utility_ProductThumbsMerge (database
DesignbestCore) - Immagini fisiche - Eseguire la query SQL per generare il file batch che sposterà i file
SELECT
'IF EXIST "' + REPLACE(old_PicturePath, '/', '\') + '" (',
'xcopy /y "' + REPLACE(old_PicturePath, '/', '\') + '" ' + '"D:\WM3Resources\ImmaginiWM3\pictures\' + EntityValue + '-'+ CAST(EntityID AS VARCHAR(10)) + '-' + CAST(ID AS VARCHAR(10)) + '.jpg*"',
') ELSE ( echo ' + CAST(ID AS VARCHAR(10)) + ' BigPath >> missingimages.txt )'
FROM Picture
- Update Width e Height delle Pictures - Eseguire script Python tramite
D:\Programs\python-CoreGetSetPicturesWidthHeight\main.batsu ARIEL (~ oo min). - Rialnciare SP -> Utility_ProductThumbsMerge (database
DesignbestCore)
Tabelle Pre-popolate
Queste tabelle sono statiche e non vengono toccate dall'importazione:
- Agent
- ShopNetStatus
- City
- Province
- Region
- Language
- Selection
- ContactRoles
- ManufacturerContractType
Cosa manca
L'importazione lascia vuote le tabelle:
- Mapping_Selection_ShopPoint
- ShopPointSponsor
SEO Title e Description
Le nuove tabelle ospitano i campi MetTitle e MetaDescription, nell'importazione vengono lasciati vuoti.
Questi dati li abbiamo, stanno nella MetaTagsHelper, dobbiamo successivamente pensare a come importarli nei campi giusti.
SEO Regole
Creata una tabella SeoRules culturizzata tramite LanguageId.
Full Text Search
La full text ha bisogno di tabelle con unica chiave primaria e i campi testuali sui quali lavorare.
Nel progetto precedente (WM5) utilizzavamo delle viste indicizzate, in questo non è più possibile farlo perché la struttura delle nuove tabelle implica delle self-join che per la fulltext non sono ammesse.
Non ci resta che optare per la creazione di tabelle apposite per la ricerca che dovranno essere mantenute aggiornate con qualche task schedulato.
Prodotti
Le tabelle implicate sono
FT_Products_ITFT_Products_ENFT_Products_FRFT_Products_DE
e vengono aggiornate (con MERGE) dalla SP FullText_Products_Update
Per effettuare una ricerca prodotti utilizzare la FullText_Products_Search
Negozi
Qui rimaniamo col vecchio approccio delle viste indicizzate dato che non ci sono self-join al momento.
Script di creazione delle viste:
DROP VIEW IF EXISTS dbo.FT_Shops_IT
GO
DROP VIEW IF EXISTS dbo.FT_Shops_EN
GO
DROP VIEW IF EXISTS dbo.FT_Shops_FR
GO
DROP VIEW IF EXISTS dbo.FT_Shops_DE
GO
-- ITALIA
CREATE VIEW dbo.FT_Shops_IT WITH SCHEMABINDING
AS
SELECT
ShopPoint.ID,
ShopPoint.[Name],
LocDescription.[LocaleValue] AS [Description],
ShopPoint.Referente,
ShopPoint.[Address],
ShopPoint.Phone,
ShopPoint.Mail,
Region.[Name] AS Region,
Province.[Name] AS Province,
City.[Name] AS City
FROM dbo.ShopPoint
INNER JOIN dbo.City ON ShopPoint.CityID = City.ID
INNER JOIN dbo.Province ON City.ProvinceID = Province.ID
INNER JOIN dbo.Region ON Province.RegionID = Region.ID
INNER JOIN dbo.LocalizedProperty AS LocDescription ON ShopPoint.ID = LocDescription.EntityId AND LocDescription.LanguageId = 1 AND LocDescription.LocaleKeyGroup = 'ShopPoint' AND LocDescription.LocaleKey = 'Description'
WHERE ShopPoint.Visible = 1 AND ShopPoint.VisibilityTypeID = 0
GO
CREATE UNIQUE CLUSTERED INDEX UQ_ID
ON dbo.FT_Shops_IT(ID)
GO
-- WORLD
CREATE VIEW dbo.FT_Shops_EN WITH SCHEMABINDING
AS
SELECT
ShopPoint.ID,
ShopPoint.[Name],
LocDescription.[LocaleValue] AS [Description],
ShopPoint.Referente,
ShopPoint.[Address],
ShopPoint.Phone,
ShopPoint.Mail,
Region.[Name] AS Region,
Province.[Name] AS Province,
City.[Name] AS City
FROM dbo.ShopPoint
INNER JOIN dbo.City ON ShopPoint.CityID = City.ID
INNER JOIN dbo.Province ON City.ProvinceID = Province.ID
INNER JOIN dbo.Region ON Province.RegionID = Region.ID
INNER JOIN dbo.LocalizedProperty AS LocDescription ON ShopPoint.ID = LocDescription.EntityId AND LocDescription.LanguageId = 2 AND LocDescription.LocaleKeyGroup = 'ShopPoint' AND LocDescription.LocaleKey = 'Description'
WHERE ShopPoint.Visible = 1 AND ShopPoint.VisibilityTypeID = 0
GO
CREATE UNIQUE CLUSTERED INDEX UQ_ID
ON dbo.FT_Shops_EN(ID)
GO
-- FRANCIA
CREATE VIEW dbo.FT_Shops_FR WITH SCHEMABINDING
AS
SELECT
ShopPoint.ID,
ShopPoint.[Name],
LocDescription.[LocaleValue] AS [Description],
ShopPoint.Referente,
ShopPoint.[Address],
ShopPoint.Phone,
ShopPoint.Mail,
Region.[Name] AS Region,
Province.[Name] AS Province,
City.[Name] AS City
FROM dbo.ShopPoint
INNER JOIN dbo.City ON ShopPoint.CityID = City.ID
INNER JOIN dbo.Province ON City.ProvinceID = Province.ID
INNER JOIN dbo.Region ON Province.RegionID = Region.ID
INNER JOIN dbo.LocalizedProperty AS LocDescription ON ShopPoint.ID = LocDescription.EntityId AND LocDescription.LanguageId = 3 AND LocDescription.LocaleKeyGroup = 'ShopPoint' AND LocDescription.LocaleKey = 'Description'
WHERE ShopPoint.Visible = 1 AND ShopPoint.VisibilityTypeID = 0
GO
CREATE UNIQUE CLUSTERED INDEX UQ_ID
ON dbo.FT_Shops_FR(ID)
GO
-- GERMANIA
CREATE VIEW dbo.FT_Shops_DE WITH SCHEMABINDING
AS
SELECT
ShopPoint.ID,
ShopPoint.[Name],
LocDescription.[LocaleValue] AS [Description],
ShopPoint.Referente,
ShopPoint.[Address],
ShopPoint.Phone,
ShopPoint.Mail,
Region.[Name] AS Region,
Province.[Name] AS Province,
City.[Name] AS City
FROM dbo.ShopPoint
INNER JOIN dbo.City ON ShopPoint.CityID = City.ID
INNER JOIN dbo.Province ON City.ProvinceID = Province.ID
INNER JOIN dbo.Region ON Province.RegionID = Region.ID
INNER JOIN dbo.LocalizedProperty AS LocDescription ON ShopPoint.ID = LocDescription.EntityId AND LocDescription.LanguageId = 4 AND LocDescription.LocaleKeyGroup = 'ShopPoint' AND LocDescription.LocaleKey = 'Description'
WHERE ShopPoint.Visible = 1 AND ShopPoint.VisibilityTypeID = 0
GO
CREATE UNIQUE CLUSTERED INDEX UQ_ID
ON dbo.FT_Shops_DE(ID)
GO
Per effettuare una ricerca negozi utilizzare la FullText_Shops_Search
Sul Server
È necessario installare .NET 7.0, la versione Hosting Bundle da https://dotnet.microsoft.com/en-us/download/dotnet/7.0
Il server non chiede riavvio.
Per verificare che il framework sia presente digitare in una console:
dotnet --list-runtimes
per vedere .NET 7
Progetto VS
Program.cs
Nel file Program.cs pregenerato aggiungere i seguenti concetti
Sessione
In un progetto Net.Core 6 la sessione parte disabilitata.
Per iniettare la sessione è necessario attivare un gestore della cache integrato (IDistributedCache) e la sessione stessa con le sue opzioni di configurazione
come da esempio
builder.Services.AddDistributedMemoryCache(); //L'implementazione IDistributedCache viene usata come archivio di backup per la sessione
builder.Services.AddSession(options => {
//options.IdleTimeout = TimeSpan.FromSeconds(10); // default 20 minuti
//options.Cookie.HttpOnly = true; // default true
options.Cookie.Name = ".Designbest.Session"; // nome del cookie di sessione
options.Cookie.IsEssential = true;
});
Dopodiché è necessario attivarla inserendo app.UseSession():
L' ordine del middleware è importante.
Chiamare UseSession dopo e UseRouting prima di MapRazorPages e MapDefaultControllerRoute
// ...
app.UseAuthorization();
app.UseSession(); // attivazione sessione
app.MapRazorPages();
app.Run();
HttpContextAccessor e HttpClient
Abilitare questi servizi
HttpContextAccessorpermette di accedere all' HttpContext da qualsiasi classe.HttpClientpermette di fare request.
builder.Services.AddHttpContextAccessor(); // per avere l'oggetto IHttpContextAccessor nelle classi
builder.Services.AddHttpClient(); // per avere l'oggetto IHttpClientFactory nelle classi
Services/Dipendenze
Implementare tutti i servizi bene separati utilizzando il pattern Interfaccia-Implementazione.
Per abilitarli nel progetto, segnalare interfaccia e implementazione come segue:
builder.Services.AddScoped<IDesignbestContext, DesignbestContext>();
builder.Services.AddScoped<IGeolocalization, MaxMindGeolocalization>();
Ci sono 3 diverse durate del servizio
- Temporaneo
builder.Services.AddTransient(..)
istanza normale che muore alla fine dello scope della funzione in cui viene chiamata - Con Ambito
builder.Services.AddScoped(..)
viene utilizzata la stessa istanza per tutta la durata della richiesta utente - Singleton
builder.Services.AddSingleton(...)
viene utilizzata la stessa istanza finche IIS non viene stoppato
Javascript
Tutti i moduli, le funzioni e gli eventi del sito sono gestiti dai nostri script che non dipendono da terze parti (tantomeno da jQuery che per i dispositivi mobile è solo un rallentamento).
Sfruttiamo le ultime funzioni standardizzate in ECMASCRIPT 6, con le definizioni di classi, metodi, membri di classe e chiamate asincrone.
Il cosiddetto thread principale prevede un bundle minificato delle nostre risorse principali.
ScriptsLoader.js
Utilizzato per caricare script di terze parti in maniera del tutto asincrona.
Espone i seguenti metodi statici:
- ScriptsLoader.GetAsynch(scriptSrc)
Carica asincronamente lo script indicato. Restiuisce una Promise.
- ScriptsLoader.InsertDefer(scriptsPath)
Inserisce nel documento l'array di script con l'attributo defer.
- ScriptsLoader.LoadScripts(scriptsPath)
Inserisce gli script di terze parti necessari a bootstrap, popper.js e bootstrap.js, dopodiché inserisce anche gli script passati come parametro (array) in maniera totalmente asincrona. Restituisce una Promise
- ScriptsLoader.DomReady(func)
Evento ready che parte al DOMContentLoaded, esegue il contenuto della funzione passata per parametro. Restituisce una Promise.
DesignbestCookiebar.js
Gestisce l'apparizione della cookiebar.
Se l'utente accetta la privacy policy,
viene iniettato lo script degli ADV di Ad Manager e viene creato un cookie di nome cookie_policy_accepted=true della durata di un anno.
Se l'utente NON accetta la privacy policy
il sito rimane senza ADV traccianti e viene creato un cookie cookie_policy_accepted=false della durata della sessione utente.
Compilazione a runtime
Abilitare la compilazione a runtime solo in ambiente di sviluppo.
- Scaricare il pacchetto nuget
Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation - Aggiungere il seguente codice al Program.cs
if (builder.Environment.IsDevelopment()) {
builder.Services.AddRazorPages().AddRazorRuntimeCompilation();
} else {
builder.Services.AddRazorPages();
}
Problema CopyRefAssembliesToPublishDirectory
Se dopo aver attivato la compilazione a Runtime il progetto dà un errore che parla di
CopyRefAssembliesToPublishDirectory
il problema potrebbe essere che dalle viste è stata fatta una @inject di un Service del quale non è stata specificata la @using all'interno di /Pages/_ViewImports.cshtml.
Mi è successo con IConfiguration, ho dovuto aggiungere questa riga
@using Microsoft.Extensions.Configuration