Skip to content

Commit 76780d7

Browse files
committed
キャッシュを導入してサーバーの負荷を下げた
1 parent c00ba06 commit 76780d7

File tree

7 files changed

+209
-41
lines changed

7 files changed

+209
-41
lines changed

apps/server/src/cache.test.ts

+36-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { memorize, TTLCache } from '@/cache';
1+
import { memorize, memorizeWithKey, purgeCache, TTLCache } from '@/cache';
22
import { sleep } from 'bun';
33
import { describe, test, setSystemTime, expect, mock } from 'bun:test';
44

55
describe('TTLCache', () => {
66
test('10sのTTLでキャッシュが期限切れになる', async () => {
77
const fetcher = mock(async () => 'value');
8-
const cache = new TTLCache<string, string>(10000);
8+
const cache = new TTLCache<string, string>(10);
99

1010
setSystemTime(new Date('2024-01-01T00:00:00Z'));
1111
expect(await cache.get('key', fetcher), 'value').toBe('value');
@@ -21,7 +21,7 @@ describe('TTLCache', () => {
2121
});
2222

2323
test('同一期間に二重でリクエストが飛ばない', async () => {
24-
const cache = new TTLCache<string, string>(10000);
24+
const cache = new TTLCache<string, string>(10);
2525

2626
const fetcher = mock(async () => {
2727
await sleep(100);
@@ -42,7 +42,7 @@ describe('TTLCache', () => {
4242
describe('memorize', () => {
4343
test('10sのTTLでキャッシュが期限切れになる', async () => {
4444
const fetcher = mock(async (a: number, b: number) => a + b);
45-
const memorized = memorize(10000, fetcher);
45+
const memorized = memorize(10, fetcher);
4646

4747
setSystemTime(new Date('2024-01-01T00:00:00Z'));
4848
expect(await memorized(1, 2)).toBe(3);
@@ -68,3 +68,35 @@ describe('memorize', () => {
6868
expect(fetcher).toHaveBeenCalledTimes(1);
6969
});
7070
});
71+
72+
describe('memorizeWithKey', () => {
73+
test('キャッシュされる', async () => {
74+
const fetcher = mock(async (a: number, b: number) => a + b);
75+
const memorized = memorizeWithKey(10, 'key', fetcher);
76+
77+
setSystemTime(new Date('2024-01-01T00:00:00Z'));
78+
expect(await memorized(1, 2)).toBe(3);
79+
expect(fetcher).toHaveBeenCalledTimes(1);
80+
81+
setSystemTime(new Date('2024-01-01T00:00:01Z'));
82+
expect(await memorized(1, 2)).toBe(3);
83+
expect(fetcher).toHaveBeenCalledTimes(1);
84+
});
85+
86+
test('purge できる', async () => {
87+
const fetcher = mock(async (a: number, b: number) => a + b);
88+
const memorized = memorizeWithKey(10, 'key', fetcher);
89+
90+
setSystemTime(new Date('2024-01-01T00:00:00Z'));
91+
expect(await memorized(1, 2)).toBe(3);
92+
expect(fetcher).toHaveBeenCalledTimes(1);
93+
94+
setSystemTime(new Date('2024-01-01T00:00:01Z'));
95+
expect(await memorized(1, 2)).toBe(3);
96+
expect(fetcher).toHaveBeenCalledTimes(1);
97+
98+
purgeCache('key');
99+
expect(await memorized(1, 2)).toBe(3);
100+
expect(fetcher).toHaveBeenCalledTimes(2);
101+
});
102+
});

apps/server/src/cache.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export class TTLCache<K, V> {
2020
private setCache(key: K, value: V) {
2121
this.cache.set(key, {
2222
value,
23-
expiresAt: Date.now() + this.ttl,
23+
expiresAt: Date.now() + this.ttl * 1000,
2424
});
2525
}
2626

@@ -56,6 +56,15 @@ export class TTLCache<K, V> {
5656

5757
return pendingPromise;
5858
}
59+
60+
list() {
61+
return [...this.cache.keys()];
62+
}
63+
64+
purge() {
65+
// purge all cache
66+
this.cache.clear();
67+
}
5968
}
6069

6170
export const memorize = <Args extends unknown[], Result>(
@@ -71,3 +80,29 @@ export const memorize = <Args extends unknown[], Result>(
7180

7281
return fetcher;
7382
};
83+
84+
const masterCache = new Map<string, TTLCache<string, unknown>>();
85+
86+
export const getCaches = () => {
87+
return [...masterCache].map(([key, cache]) => ({ key, cache: cache.list() }));
88+
};
89+
90+
export const purgeCache = (key: string) => {
91+
masterCache.get(key)?.purge();
92+
};
93+
94+
export const memorizeWithKey = <Args extends unknown[], Result>(
95+
ttl: number,
96+
key: string,
97+
fn: (...args: Args) => Promise<Result>,
98+
): ((...args: Args) => Promise<Result>) => {
99+
const cache = new TTLCache<string, Result>(ttl);
100+
masterCache.set(key, cache);
101+
102+
const fetcher = async (...args: Args): Promise<Result> => {
103+
const key = JSON.stringify(args);
104+
return cache.get(key, () => fn(...args));
105+
};
106+
107+
return fetcher;
108+
};

apps/server/src/gateway.ts

+64-5
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,33 @@
1-
import { memorize } from '@/cache';
1+
import { memorize, memorizeWithKey, purgeCache } from '@/cache';
22
import { api, machineToken } from '@/traQ/api';
3+
import {
4+
getChannelMessageRanking,
5+
getChannelStampsRanking,
6+
getGaveMessageStampsRanking,
7+
getMessageContents,
8+
getMessages,
9+
getMessagesRanking,
10+
getMessagesTimeline,
11+
getReceivedMessageStampsRanking,
12+
getStampRanking,
13+
getStampRelations,
14+
getStamps,
15+
getStampsMeanUsage,
16+
getSubscriptionRanking,
17+
getTagRanking,
18+
getUserGroupRanking,
19+
} from '@traq-ing/database';
320
import sharp from 'sharp';
421

522
const getAuthHeader = (token: string) => ({ headers: { Authorization: `Bearer ${token}` } });
623

7-
export const getMe = memorize(1000, async (token: string) => {
24+
export const getMe = memorize(10, async (token: string) => {
825
const res = await api.users.getMe(getAuthHeader(token));
926
if (!res.ok) throw new Error('Failed to fetch user');
1027
return res.data;
1128
});
1229

13-
export const getSubscriptions = memorize(100, async (token: string) => {
30+
export const getSubscriptions = memorize(0, async (token: string) => {
1431
const res = await api.users.getMyChannelSubscriptions(getAuthHeader(token));
1532
if (!res.ok) throw new Error('Failed to fetch subscriptions');
1633
return res.data;
@@ -22,13 +39,13 @@ export const setSubscriptionLevel = async (token: string, channelId: string, lev
2239
return res.data;
2340
};
2441

25-
export const getMessage = memorize(1000, async (messageId: string) => {
42+
export const getMessage = memorize(3600, async (messageId: string) => {
2643
const res = await api.messages.getMessage(messageId);
2744
if (!res.ok) throw new Error('Failed to fetch message');
2845
return res.data;
2946
});
3047

31-
export const getUsers = memorize(1000, async () => {
48+
export const getUsers = memorize(3600, async () => {
3249
const res = await api.users.getUsers({ 'include-suspended': true });
3350
if (!res.ok) throw new Error('Failed to fetch users');
3451
return res.data;
@@ -103,3 +120,45 @@ export const getOgInfo = memorize(0, async (url: string) => {
103120

104121
return { title, description, image, origin, type };
105122
});
123+
124+
export const getMessagesCached = memorizeWithKey(3600, 'messages', getMessages);
125+
export const getMessageContentsCached = memorizeWithKey(3600, 'messageContents', getMessageContents);
126+
export const getChannelMessageRankingCached = memorizeWithKey(3600, 'channelMessageRanking', getChannelMessageRanking);
127+
export const getMessagesRankingCached = memorizeWithKey(3600, 'messagesRanking', getMessagesRanking);
128+
export const getMessagesTimelineCached = memorizeWithKey(3600, 'messagesTimeline', getMessagesTimeline);
129+
export const getStampRankingCached = memorizeWithKey(3600, 'stampRanking', getStampRanking);
130+
export const getChannelStampsRankingCached = memorizeWithKey(3600, 'channelStampsRanking', getChannelStampsRanking);
131+
export const getGaveMessageStampsRankingCached = memorizeWithKey(
132+
3600,
133+
'gaveMessageStampsRanking',
134+
getGaveMessageStampsRanking,
135+
);
136+
export const getReceivedMessageStampsRankingCached = memorizeWithKey(
137+
3600,
138+
'receivedMessageStampsRanking',
139+
getReceivedMessageStampsRanking,
140+
);
141+
export const getUserGroupRankingCached = memorizeWithKey(3600, 'userGroupRanking', getUserGroupRanking);
142+
export const getTagRankingCached = memorizeWithKey(3600, 'tagRanking', getTagRanking);
143+
export const getSubscriptionRankingCached = memorizeWithKey(3600, 'subscriptionRanking', getSubscriptionRanking);
144+
export const getStampsCached = memorizeWithKey(3600, 'stamps', getStamps);
145+
export const getStampsMeanUsageCached = memorizeWithKey(3600, 'stampsMeanUsage', getStampsMeanUsage);
146+
export const getStampRelationsCached = memorizeWithKey(3600, 'stampRelations', getStampRelations);
147+
148+
export const forgotCaches = () => {
149+
purgeCache('messages');
150+
purgeCache('messageContents');
151+
purgeCache('channelMessageRanking');
152+
purgeCache('messagesRanking');
153+
purgeCache('messagesTimeline');
154+
purgeCache('stampRanking');
155+
purgeCache('channelStampsRanking');
156+
purgeCache('gaveMessageStampsRanking');
157+
purgeCache('receivedMessageStampsRanking');
158+
purgeCache('userGroupRanking');
159+
purgeCache('tagRanking');
160+
purgeCache('subscriptionRanking');
161+
purgeCache('stamps');
162+
purgeCache('stampsMeanUsage');
163+
purgeCache('stampRelations');
164+
};

apps/server/src/index.ts

+55-31
Original file line numberDiff line numberDiff line change
@@ -4,43 +4,50 @@ import {
44
MessagesQuerySchema,
55
StampRelationsQuerySchema,
66
StampsQuerySchema,
7-
getChannelMessageRanking,
8-
getChannelStampsRanking,
9-
getGaveMessageStampsRanking,
10-
getMessages,
11-
getMessageContents,
12-
getMessagesRanking,
13-
getMessagesTimeline,
14-
getReceivedMessageStampsRanking,
15-
getStampRanking,
16-
getStampRelations,
17-
getStamps,
18-
getSubscriptionRanking,
19-
getTagRanking,
20-
getUserGroupRanking,
217
StampsMeanUsageQuerySchema,
22-
getStampsMeanUsage,
238
} from '@traq-ing/database';
249
import { Hono } from 'hono';
2510
import { getCookie } from 'hono/cookie';
2611
import { z } from 'zod';
2712
import { tokenKey, traqAuthRoutes } from './auth';
2813
import {
14+
forgotCaches,
15+
getChannelMessageRankingCached,
2916
getChannels,
17+
getChannelStampsRankingCached,
3018
getChannelSubscribers,
3119
getFile,
20+
getGaveMessageStampsRankingCached,
3221
getMe,
3322
getMessage,
23+
getMessageContentsCached,
24+
getMessagesCached,
25+
getMessagesRankingCached,
3426
getMessageStamps,
27+
getMessagesTimelineCached,
3528
getOgInfo,
29+
getReceivedMessageStampsRankingCached,
30+
getStampRankingCached,
31+
getStampRelationsCached,
32+
getStampsCached,
33+
getStampsMeanUsageCached,
34+
getSubscriptionRankingCached,
3635
getSubscriptions,
36+
getTagRankingCached,
37+
getUserGroupRankingCached,
3738
getUserGroups,
3839
getUsers,
3940
setSubscriptionLevel,
4041
} from '@/gateway';
4142
import { HTTPException } from 'hono/http-exception';
4243
import { createMiddleware } from 'hono/factory';
4344
import { extractWords } from '@/extractor';
45+
import { getCaches } from '@/cache';
46+
47+
const secretKey = process.env.SECRET_KEY;
48+
if (secretKey === undefined) {
49+
throw new Error('SECRET_KEY is required');
50+
}
4451

4552
const app = new Hono<{
4653
Variables: { token: string };
@@ -84,15 +91,15 @@ const routes = app
8491
},
8592
)
8693
.get('/messages', zValidator('query', MessagesQuerySchema), cacheMiddleware, async (c) =>
87-
c.json(await getMessages(c.req.valid('query')), 200),
94+
c.json(await getMessagesCached(c.req.valid('query')), 200),
8895
)
8996
.get(
9097
'/message-contents',
9198
zValidator('query', MessageContentsQuerySchema.omit({ offset: true })),
9299
cacheMiddleware,
93100
async (c) => {
94101
const { limit, ...query } = c.req.valid('query');
95-
const contents = await getMessageContents(query);
102+
const contents = await getMessageContentsCached(query);
96103
const words = [];
97104
for (const content of contents) {
98105
words.push(...(await extractWords(content.content)));
@@ -105,40 +112,42 @@ const routes = app
105112
return c.json(wordCount.slice(0, limit), 200);
106113
},
107114
)
108-
.get('/channel-messages-ranking', cacheMiddleware, async (c) => c.json(await getChannelMessageRanking(), 200))
109-
.get('/messages-ranking', cacheMiddleware, async (c) => c.json(await getMessagesRanking(), 200))
110-
.get('/messages-timeline', cacheMiddleware, async (c) => c.json(await getMessagesTimeline(), 200))
111-
.get('/stamp-ranking', cacheMiddleware, async (c) => c.json(await getStampRanking(), 200))
112-
.get('/channel-stamps-ranking', cacheMiddleware, async (c) => c.json(await getChannelStampsRanking(), 200))
113-
.get('/gave-stamps-ranking', cacheMiddleware, async (c) => c.json(await getGaveMessageStampsRanking(), 200))
114-
.get('/received-stamps-ranking', cacheMiddleware, async (c) => c.json(await getReceivedMessageStampsRanking(), 200))
115+
.get('/channel-messages-ranking', cacheMiddleware, async (c) => c.json(await getChannelMessageRankingCached(), 200))
116+
.get('/messages-ranking', cacheMiddleware, async (c) => c.json(await getMessagesRankingCached(), 200))
117+
.get('/messages-timeline', cacheMiddleware, async (c) => c.json(await getMessagesTimelineCached(), 200))
118+
.get('/stamp-ranking', cacheMiddleware, async (c) => c.json(await getStampRankingCached(), 200))
119+
.get('/channel-stamps-ranking', cacheMiddleware, async (c) => c.json(await getChannelStampsRankingCached(), 200))
120+
.get('/gave-stamps-ranking', cacheMiddleware, async (c) => c.json(await getGaveMessageStampsRankingCached(), 200))
121+
.get('/received-stamps-ranking', cacheMiddleware, async (c) =>
122+
c.json(await getReceivedMessageStampsRankingCached(), 200),
123+
)
115124
.get(
116125
'/group-ranking',
117126
zValidator('query', z.object({ groupBy: z.enum(['user', 'group']) })),
118127
cacheMiddleware,
119-
async (c) => c.json(await getUserGroupRanking(c.req.valid('query').groupBy), 200),
128+
async (c) => c.json(await getUserGroupRankingCached(c.req.valid('query').groupBy), 200),
120129
)
121130
.get(
122131
'/tag-ranking',
123132
zValidator('query', z.object({ groupBy: z.enum(['user', 'tag']) })),
124133
cacheMiddleware,
125-
async (c) => c.json(await getTagRanking(c.req.valid('query').groupBy), 200),
134+
async (c) => c.json(await getTagRankingCached(c.req.valid('query').groupBy), 200),
126135
)
127136
.get(
128137
'/subscription-ranking',
129138
zValidator('query', z.object({ groupBy: z.enum(['user', 'channel']) })),
130139
cacheMiddleware,
131-
async (c) => c.json(await getSubscriptionRanking(c.req.valid('query').groupBy), 200),
140+
async (c) => c.json(await getSubscriptionRankingCached(c.req.valid('query').groupBy), 200),
132141
)
133142
.get('/messages/:id', cacheMiddleware, async (c) => c.json(await getMessage(c.req.param('id')), 200))
134143
.get('/stamps', zValidator('query', StampsQuerySchema), async (c) =>
135-
c.json(await getStamps(c.req.valid('query')), 200),
144+
c.json(await getStampsCached(c.req.valid('query')), 200),
136145
)
137146
.get('/stamps-mean-usage', zValidator('query', StampsMeanUsageQuerySchema), async (c) =>
138-
c.json(await getStampsMeanUsage(c.req.valid('query')), 200),
147+
c.json(await getStampsMeanUsageCached(c.req.valid('query')), 200),
139148
)
140149
.get('/stamp-relations', zValidator('query', StampRelationsQuerySchema), async (c) =>
141-
c.json(await getStampRelations(c.req.valid('query')), 200),
150+
c.json(await getStampRelationsCached(c.req.valid('query')), 200),
142151
)
143152
.get('/users', cacheMiddleware, async (c) => c.json(await getUsers(), 200))
144153
.get('/groups', cacheMiddleware, async (c) => c.json(await getUserGroups(), 200))
@@ -170,7 +179,22 @@ const routes = app
170179
c.header('Cache-Control', 'private, max-age=31536000');
171180
return c.newResponse(file.stream());
172181
},
173-
);
182+
)
183+
.get('/caches', async (c) => {
184+
if (c.req.header('x-secret-key') !== secretKey) {
185+
throw new HTTPException(401, { message: 'Unauthorized' });
186+
}
187+
188+
return c.json(getCaches(), 200);
189+
})
190+
.delete('/caches', async (c) => {
191+
if (c.req.header('x-secret-key') !== secretKey) {
192+
throw new HTTPException(401, { message: 'Unauthorized' });
193+
}
194+
forgotCaches();
195+
console.log('Caches cleared');
196+
return c.json({ message: 'Caches cleared' }, 204);
197+
});
174198

175199
app.on(
176200
'GET',
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import { dropMessagesAndStamps, updateMessages } from '@/traQ';
22

3+
const baseUrl = process.env.SERVER_URL;
4+
if (!baseUrl) throw new Error('SERVER_URL is not set');
5+
const secret = process.env.SECRET_KEY;
6+
if (!secret) throw new Error('SECRET_KEY is not set');
7+
38
await dropMessagesAndStamps();
49
await updateMessages(true);
10+
await fetch(`${baseUrl}/caches`, { method: 'DELETE', headers: { 'X-Secret-Key': secret } });

0 commit comments

Comments
 (0)