Use Cases
乾貨
Detect Dark Mode
body {
font-weight: 400;
background-color: #fdfdfd;
color: #212121;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
color: #1a1a1a;
}
/* Dark mode CSS */
@media (prefers-color-scheme: dark) {
body {
font-weight: 350;
background-color: #12121f;
color: #fbfbfb;
}
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
color: #fff;
}
}
Open Graph Meta
Facebook Debug Tools
<!doctype html>
<html lang="zh-TW">
<head>
<title>國家兩廳院</title>
<meta charset="utf-8"/>
<meta name="viewport" content="initial-scale=1,maximum-scale=5" />
<meta name="description" content="兩廳院是臺灣最成熟的國際級藝術中心,也是亞洲具指標性的當代劇場,無論當代或傳統、原生或外來,都在兩廳院的舞臺上共生,且深受臺灣觀眾青睞。這片土地的自由與開放讓多元文化彼此對話,自由帶來空間,開放帶來思考,是兩廳院存在的立基,也讓兩廳院成為亞洲最自由與開放的文化場景。" />
<meta name="google-site-verification" content="8TNz1gjBqx7XLyTvkNXtez_4yh-4QOBiB9In-vy_jZk" />
<meta property="og:title" content="國家兩廳院 NTCH" />
<meta property="og:description" content="兩廳院是臺灣最成熟的國際級藝術中心,也是亞洲具指標性的當代劇場,無論當代或傳統、原生或外來,都在兩廳院的舞臺上共生,且深受臺灣觀眾青睞。這片土地的自由與開放讓多元文化彼此對話,自由帶來空間,開放帶來思考,是兩廳院存在的立基,也讓兩廳院成為亞洲最自由與開放的文化場景。" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://vfms-file-test.npac-ntch.org/b3fe4353497bfe07770adcc82546ca468335c10b.jpeg" />
</head>
<body>
/* .... */
</body>
</html>
Open Graph Meta Tag
Model Answer
SSR
Server Side Rendering
But, I'm lazy and no time to do
Detect Crawler
# nginx.conf
upstream open_graph_upstream {
server OpenGraph;
}
location ~* ^/programs/\d {
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
if ($http_user_agent ~ (facebookexternalhit|line-poker|TelegramBot|TwitterBot|Slackbot)) {
proxy_pass http://open_graph_upstream;
}
try_files $uri /$uri /index.html;
}
facebookexternalhit / line-poker / TelegramBot / TwitterBot / Slackbot
User Agent
Render Meta-only
router.get('/programs/:programId', async (ctx) => {
const { programId } = ctx.params;
debugOpenGraph(`Fetched: Program ${programId}`);
const language = ctx.headers['accept-language']?.match(/en(-US)?/) ? 'en-US' : 'zh-TW';
const program = await getProgram(null, { id: programId }, { language });
if (!program) {
ctx.status = 404;
} else {
ctx.body = `<!DOCTYPE html>
<html lang="${language}">
<head>
<title>${program.title} ${program.engTitle}</title>
<meta charset='utf-8' />
<meta name='viewport' content='initial-scale=1, maximum-scale=1' />
<meta name='description' content="${program.brief}" />
<meta property="og:url" content="${OFFICIAL_SITE_HOST}/programs/${programId}" />
<meta property="og:locale" content="${language === 'en-US' ? 'en_US' : 'zh_TW'}" />
<meta property="og:title" content="${language === 'en-US' ? program.engTitle : program.title}" />
<meta property="og:description" content="${program.brief}" />
<meta property="og:image" content="${STATIC_HOST}/${program.cover}" />
<meta property="og:type" content="article" />
<meta property="fb:app_id" content="1606444052877313" />
</head>
<body>
<h1>${language === 'en-US' ? program.engTitle : program.title}</h1>
<p>${program.brief}</p>
<article>
${program.content}
</article>
<p id="tags">
${[...program.series, ...program.tags].map(s => s.name).join(', ')}
</p>
${program.purchaseLink ? `<a href=${program.purchaseLink} target="_blank">立即購買</a>` : ''}
</body>
</html>
`;
}
});
Results
Remember!
Customize meta response not making SEO ranking better. If you concerned result ranking, please use SSR, sitemap...etc.
Async File Downloader
Async Condition
To export large scaled records or generate webpage screen shot, server uses aggregated time more than 30 seconds. It causes aggressive CDNs cut down the request with 502 response.
Solutions
Send Email
Server Push
Subscription / Event Source
Flows
Client
useQuery
generateProgramPreviewPDF
Resolver
Puppetter
Resolver
asyncFileId
useSubscription
asyncFileDownloadUrl(asyncFileId)
Static Filename
Generated Filename
In my case
Client
useQuery
generateProgramPreviewPDF
Resolver
Puppetter
Resolver
asyncFileId
useSubscription
asyncFileDownloadUrl(asyncFileId)
Static Filename
Generated Filename
GRPC
WebSocket
GraphQL
Worker
export async function generatePreviewPDF(id, member) {
const program = await models.Program.findById(id);
if (!program) throw new ProgramNotFoundError();
if (!await program.shouldMemberHasPermissionToChange(member)) throw new PermissionError();
const m = await models.Member.findById(member.id);
const refreshToken = await m.getRefreshToken();
const asyncFileId = uuid();
pdfGenerationClient.generateApplyPreviewPDF({
programId: program.id,
refreshToken,
}, (err, response) => {
if (err) {
debugGenerateApplyPreviewPDF(`Generate Failed: ${err}`);
return;
}
debugGenerateApplyPreviewPDF(`Preview (Program: ${program.id}) File Generated: ${response.filename}`);
fileUploaderClient.updateAsyncFileUploaderUrl({
id: asyncFileId,
filename: response.filename,
}, (updateErr) => {
if (updateErr) {
debugGenerateApplyPreviewPDF(`Update Async File Uploader URI: ${updateErr}`);
}
});
});
return asyncFileId;
}
service FileUploader {
rpc updateAsyncFileUploaderUrl(UploadedFile) returns (EmptyResponse) {}
}
message UploadedFile {
required string id = 1;
required string filename = 2;
}
message EmptyResponse {
}
GRPC Proto
const { programId } = useParams();
const [watchAsyncFileId, setAsyncFileId] = useState(null);
const [fetchURL, { loading: urlLoading }] = useMutation(GENERATE_PROGRAM_PREVIEW_PDF, {
variables: {
id: programId,
},
});
const { data, variables } = useSubscription(ASYNC_FILE_UPLOAD_SUBSCRIPTION, {
variables: {
fileId: watchAsyncFileId,
},
skip: !watchAsyncFileId,
});
const loading = useMemo(() => urlLoading, [urlLoading]);
const doAction = useCallback(async () => {
const response = await fetchURL();
if (response
&& response.data
&& response.data.generateProgramPreviewPDF) {
const asyncFileId = response.data.generateProgramPreviewPDF;
setAsyncFileId(asyncFileId);
}
}, [fetchURL]);
if (watchAsyncFileId
&& watchAsyncFileId === variables.fileId
&& data?.asyncFileDownloadUrl) {
return (
<div style={styles.wrapper}>
<a
target="_blank"
rel="noopener noreferrer"
href={`${STATIC_HOST}/${data?.asyncFileDownloadUrl}`}
css={styles.btn}>
下載 PDF
</a>
<p style={styles.helper}>檔案製作完成</p>
</div>
);
}
return (
<div style={styles.wrapper}>
<button
type="button"
disabled={loading}
onClick={doAction}
css={[
styles.btn,
loading && styles.btnLoading,
]}>
{loading || watchAsyncFileId ? (
<LoadingSpinner />
) : '下載 PDF'}
</button>
{loading || watchAsyncFileId ? (
<p style={styles.helper}>檔案製作中..</p>
) : null}
</div>
);
Dynamic Load Third Party Scripts
Senario
By YouTube, Google Maps, Facebook..., third party service script should load when user request the services.
For performance, we prefer to load scripts when it used.
Example: YouTube
Src: https://www.youtube.com/iframe_api
OnLoad Callback: onYouTubeIframeAPIReady
Asynchronous loading should handing with React render ticks.
const YOUTUBE_API_SRC = 'https://www.youtube.com/iframe_api';
const youtubeOnLoadedTasks = [];
window.isYoutubeStartedLoading = false;
window.onYouTubeIframeAPIReady = function onYouTubeIframeAPIReady() {
youtubeOnLoadedTasks.forEach((task) => task());
};
function useYoutubePlayer() {
const loadPlayer = useCallback(() => {
// do something required youtube iframe api
}, []);
useEffect(() => {
let cancelled = false;
if (typeof YT !== 'undefined') {
loadPlayer();
return () => {};
}
youtubeOnLoadedTasks.push(() => {
if (cancelled) return;
loadPlayer();
});
if (!window.isYoutubeStartedLoading) {
const scriptTag = document.createElement('script');
scriptTag.src = YOUTUBE_API_SRC;
document.body.appendChild(scriptTag);
window.isYoutubeStartedLoading = true;
}
return () => { cancelled = true; };
}, []);
}
Article Views
Scenario
We need to record article views count each query from server, but in scalable system, this behavior will be a bottleneck.
When you update views counter in database, it will lock the data row (or whole table with bad index configuration), affecting the query performance.
Easy Solution
- Cache the views counter value in memory
- Write back to database in long period
Easy Solution
- Cache the views counter value in memory
- Write back to database in long period
Risk
- Accident shutdown
- Long term value sync issues between multiple application nodes
Fantastic Solution
Stream Processing
In memory database
In my case
consume
Views Counter
Consumer
Produce Kafka Message
Update everytime
Consumer
subscribe
consume
subscribe
Update Cached View
Get Cached View
Redis
Full Logs
Dedicated Table
Get Cached View
Update periodly
Aggragated
ArticleViews
ArticleViewLogs
const REDIS_URI = process.env.REDIS_URI || 'redis://127.0.0.1:6379';
const redisClient = redis.createClient({ url: REDIS_URI });
const CACHE_EXPIRE_SECONDS = 4 * 60 * 60; // 4 hours;
export async function getArticleViews(articleId: string): Promise<number> {
return new Promise((resolve) => {
redisClient.get(articleId, async (_, views) => {
if (views) {
resolve(Number(views));
} else {
const manager = await getManager();
const articleViewRecord = await manager.findOne(ArticleView, { ArticleId: articleId });
if (articleViewRecord) {
redisClient.set(articleId, articleViewRecord.views.toString());
redisClient.expire(articleId, CACHE_EXPIRE_SECONDS);
resolve(articleViewRecord.views);
} else {
redisClient.set(articleId, '0');
redisClient.expire(articleId, CACHE_EXPIRE_SECONDS);
resolve(0);
}
}
});
});
}
Get from cache
When miss cache, find from aggregated table.
import { Kafka, logLevel } from 'kafkajs';
const KAFKA_CLIENT_ID = process.env.KAFKA_CLIENT_ID || 'wealth-usage-listener';
const KAFKA_BROKER_URI = process.env.KAFKA_BROKER_URI || 'localhost:7092';
const KAFKA_ARTICLE_VIEW_TOPIC = process.env.KAFKA_ARTICLE_VIEW_TOPIC || 'wealth-article-views';
const kafka = new Kafka({
logLevel: logLevel.INFO,
brokers: [KAFKA_BROKER_URI],
clientId: KAFKA_CLIENT_ID,
});
const initQueue: QueueRecord[] = [];
export default async function init() {
if (isInitialing || isConnected) {
return;
}
debugKafkaProducer('Connecting Producer...');
isInitialing = true;
producer.on('producer.connect', () => {
initQueue.map((record) => async () => {
await producer.send({
topic: KAFKA_ARTICLE_VIEW_TOPIC,
messages: [
{
key: 'id',
value: record.articleId,
...(record.memberId ? {
headers: {
memberId: record.memberId,
},
} : {}),
},
],
});
}).reduce((prev, next) => prev.then(next), Promise.resolve());
});
await producer.connect();
isConnected = true;
}
Create Producer
export async function recordArticleView(
articleId: string,
memberId?: string | undefined,
): Promise<void> {
redisClient.get(articleId, (_, views) => {
if (views) {
redisClient.set(articleId, (Number(views) + 1).toString());
} else {
redisClient.set(articleId, '1');
}
redisClient.expire(articleId, CACHE_EXPIRE_SECONDS);
});
if (!isConnected) {
debugKafkaProducer('Kafka Producer is not connected.');
initQueue.push({ articleId, memberId });
if (!isInitialing) {
init();
}
return;
}
await producer.send({
topic: KAFKA_ARTICLE_VIEW_TOPIC,
messages: [
{
key: 'id',
value: articleId,
...(memberId ? {
headers: {
memberId,
},
} : {}),
},
],
});
}
Producer
const KAFKA_CONSUMER_GROUP_ID = process.env.KAFKA_CONSUMER_GROUP_ID || 'wealth-consumer';
const KAFKA_ARTICLE_VIEW_TOPIC = process.env.KAFKA_ARTICLE_VIEW_TOPIC || 'wealth-article-views';
const ARTICLE_VIEW_UPDATE_FEQ_IN_MS = Number(process.env.ARTICLE_VIEW_UPDATE_FEQ_IN_MS || '30000'); // 30 sec
const consumer = kafka.consumer({ groupId: KAFKA_CONSUMER_GROUP_ID });
export default async function run() {
await consumer.subscribe({ topic: KAFKA_ARTICLE_VIEW_TOPIC });
let lastSync = Date.now();
await consumer.run({
eachMessage: async ({ topic, message }) => {
const articleId = message.value!.toString();
if (topic === KAFKA_ARTICLE_VIEW_TOPIC && message?.key?.toString() === 'id') {
const manager = await getManager();
const log = manager.create(ArticleViewLog, {
ArticleId: articleId,
MemberId: message.headers?.memberId?.toString() ?? undefined,
});
await manager.save(log);
}
if (ARTICLE_VIEW_UPDATE_FEQ_IN_MS
&& (Date.now() - lastSync) > ARTICLE_VIEW_UPDATE_FEQ_IN_MS) {
const manager = await getManager();
const count = await manager.count(ArticleViewLog, { ArticleId: articleId });
const viewRecord = await manager.findOne(ArticleView, articleId);
if (viewRecord) {
viewRecord.views = count;
viewRecord.updatedAt = new Date();
await manager.save(viewRecord);
} else {
const newRecord = manager.create(ArticleView, {
ArticleId: articleId,
views: count,
});
await manager.save(newRecord);
}
lastSync = Date.now();
debugKafkaConsumer(`Article ${articleId} updated views to ${count}`);
}
},
});
}
Create Consumer
import debug from 'debug';
import Koa from 'koa';
import { createConnection } from 'typeorm';
import { getArticleViews } from './worker';
const debugServer = debug('Wealth:UsageListenerServer');
const USAGE_LISTENER_PORT = Number(process.env.USAGE_LISTENER_PORT || '6068');
const app = new Koa();
app.use(async (ctx) => {
const articleId = ctx.url.replace(/^\//, '');
ctx.body = await getArticleViews(articleId);
});
createConnection().then(() => {
app.listen(USAGE_LISTENER_PORT, () => {
debugServer(`Usage Listener Server listen on ${USAGE_LISTENER_PORT}`);
});
});
Export Service
Image Resources
Placeholder Image
Illutrators
CSS Background Pattern
Cảm ơn bạn
Frequently Use Cases
By Chia Yu Pai
Frequently Use Cases
- 557