Node.js'te yer alan streams
verileri okuma ve yazma işlemlerini kolaylaştıran güçlü bir konsepttir. Streams
, büyük veri miktarlarını işlerken hafızayı/belleği/memory'i etkin bir şekilde kullanmanıza yardımcı olurlar.
Node.js'te üç tür stream bulunur:
- Readable: Veri okuma işlemleri için kullanılır.
- Writable: Veri yazma işlemleri için kullanılır.
- Duplex: Hem okuma hem de yazma işlemleri için kullanılır.
Öncelikle veri okumak için faker
paketini kullanarak fake bir veri dosyası oluşturalım.
import fs from "fs";
import { faker } from "@faker-js/faker";
const writeStream = fs.createWriteStream("./data/import.csv");
writeStream.write("name;email;age;salary;isActive\n");
for (let index = 0; index < 50; index++) {
const firstName = faker.person.firstName();
const email = faker.internet.email({ firstName });
const age = faker.number.int({ min: 10, max: 100 });
const salary = faker.string.numeric(4, { allowLeadingZeros: true });
const active = faker.datatype.boolean();
const arr = [firstName, email, age, salary, active];
writeStream.write(arr.join(";") + "\n");
}
writeStream.end();
Yukarıdaki kodda faker
paketini kullanarak 50 adet fake veri oluşturduk ve data/import.csv
dosyasına yazdık.
Biz bu verileri, stream
kullanarak okuyacağız:
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv");
readStream.on("data", (chunk) => {
console.log("BUFFER >>>");
console.log(chunk);
});
})();
Yukarıdaki kodda fs
paketini kullanarak data/import.csv
dosyasını okuduk ve chunk
adı verilen parçalar halinde konsola yazdırdık.
on
fonksiyonu, stream
'in bir event'i dinlemesini sağlar. data
olayı, stream
'in veri okuması event'idir. Zaten genelde en çok kullanılan iki event data
ve end
event'idir. Tüm event'lerin listesi için, bkz.
Biz data
event'ini seçtiğimizde callback fonksiyonu içerisindeki chunk
parametresi, stream
'in okuduğu veriyi temsil eder ve bu veri, string
ya da Buffer
tipinde bir veridir. Buffer
tipi, Node.js'te verilerin okunması ve yazılması için kullanılan bir veri tipidir ve aslında Golang'teki byte
tipine benzer.
Yazdığımız kodu çalıştırdığımızda ise aşağıdaki gibi bir çıktı alırız:
Gördüğünüz gibi chunk
parametresi, Buffer
tipinde bir veri olarak gelmektedir. Bu veriyi string
tipine çevirmek için toString
fonksiyonunu kullanabiliriz:
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv");
readStream.on("data", (chunk) => {
console.log("BUFFER >>>");
console.log(chunk.toString());
});
})();
Yukarıdaki kodu çalıştırdığımızda ise aşağıdaki gibi bir çıktı alırız:
Peki tüm bu stream
in bitip bitmediğini nasıl anlayacağız? Bunun için end
event'ini kullanabiliriz:
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv");
readStream.on("data", (chunk) => {
console.log("BUFFER >>>");
console.log(chunk.toString());
});
readStream.on("end", () => {
console.log("END OF STREAM");
});
})();
highWaterMark
özelliği, stream
'in okuyabileceği maksimum veri miktarını belirler. Bu özellik, stream
'in belleği ne kadar kullanacağını belirler. Varsayılan olarak bu değer 16 KB
'dır. Biz bu değeri 1 KB
olarak değiştirelim:
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv", {
highWaterMark: 1024,
});
readStream.on("data", (chunk) => {
console.log("BUFFER >>>");
console.log(chunk.toString());
});
readStream.on("end", () => {
console.log("END OF STREAM");
});
})();
Bundan böyle stream
'in belleği 1 KB
'lık parçalar halinde kullanacağını göreceksiniz. Yani, stream
'in belleği 1 KB
'lık parçalar halinde kullanıldıktan sonra, stream
'in belleği boşalana kadar yeni bir parça okunmayacaktır. Tıpkı bir borunun içerisindeki suyun, borunun ucuna ulaşana kadar birikmesi gibi. Bu özelliği, stream
'in belleği ne kadar kullanacağını belirlemek için kullanabilirsiniz.
Bu kodun çıktısını ise aşağıdaki gibi alırsınız:
Toparlayacak olursak, highWaterMark
özelliği, bir Readable Stream'in içinde bulunan verilerin okunma hızını ve boyutunu kontrol etmek için kullanılır. Bu özellik, verilerin ne kadarlık bir boyutta okunacağını belirler. Özellikle büyük dosyaları okurken bellek kullanımını kontrol etmek için önemlidir.
Örnekte verilen kodda highWaterMark
1024
olarak ayarlanmıştır. Bu, her seferinde 1024
byte'lık bir veri yığını okunmasını sağlar. Yani, dosyanın içeriği 1024
byte'lık parçalara bölünerek okunur ve data
event'i tetiklendiğinde bu parçalar birer birer işlenir.
Bu, bellek kullanımını kontrol etmek ve büyük dosyaları daha küçük parçalara bölmek için kullanışlıdır. Özellikle büyük dosyaları okurken veya ağ üzerinden veri akışını işlerken highWaterMark
değeri ayarlamak, performans ve bellek yönetimi açısından önemlidir.
Şimdi de stream
kullanarak veri yazmayı görelim:
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv", {
highWaterMark: 800,
});
const writeStream = fs.createWriteStream("./data/export.csv");
readStream.on("data", (chunk) => {
console.log("BUFFER >>>");
console.log(chunk.toString());
writeStream.write(chunk);
});
readStream.on("end", () => {
console.log("END OF STREAM");
writeStream.end();
});
})();
Yukarıdaki kodda ilk olarak data/import.csv
dosyasını okuyup, data/export.csv
dosyasına yazdık. Ardında readStream
'in data
event'ini dinleyerek, chunk
parametresini writeStream
'e yazdırdık. Son olarak da readStream
'in end
event'ini dinleyerek, writeStream
'i kapattık. Böylece okumuş olduğumuz verileri writeStream
'e yazdırmış olduk.
createWriteStream
in bu şekilde kullanılması backpressure
sorununa yol açabilir. backpressure
sorunu, stream
'in okuma (read) hızının yazma (write) hızından daha yavaş olması durumunda ortaya çıkar (veya tam tersi). Bu durumda stream
'in belleği dolar ve stream
'in belleği dolduğu için yeni bir veri okunamaz veya tam tersi durumda yeni bir veri yazılamaz.
Bu konuyla ilgili güzel bir makale için, bkz. Backpressuring in Streams.
pipe
fonksiyonunu kullanarak, backpressure
sorununu çözebiliriz. pipe
fonksiyonu, bir stream
'in çıktısını başka bir stream
'e bağlamak için kullanılır. Yani, pipe
fonksiyonu, bir stream
'in çıktısını başka bir stream
'e bağlar. Bu sayede stream
'in çıktısı, başka bir stream
'e yazılabilir. Adından (pipe) da anlaşılacağı üzere, gerçekten de bir boru gibi çalışır. Boruların çalışma mantığına benzer şekilde, bir stream
'in çıktısı başka bir stream
'e bağlanır ve bu sayede backpressure
sorunu çözülmüş olur. Ayrıca çok daha basit bir yapıya sahip olur.
import fs from "fs";
(async () => {
const readStream = fs.createReadStream("./data/import.csv");
const writeStream = fs.createWriteStream("./data/export.csv");
readStream.pipe(writeStream);
readStream.on("end", () => {
console.log("Stream ended");
});
writeStream.on("finish", () => {
console.log("Write stream finished");
});
})();
Yukarıdaki kodda pipe
fonksiyonunu kullanarak, readStream
'in çıktısını writeStream
'e bağladık. Bu sayede backpressure
sorununu çözmüş olduk. Ayrıca readStream
'in end
event'ini ve writeStream
'in de finish
event'ini dinleyerek, stream
'lerin bitmesi durumunda konsola mesaj yazdırdık.
Gerçek hayatta bu pipe
fonksiyonunu kullanmak için öncelikle büyük boyutlarda bir dosya oluşturalım:
import fs from "fs";
const file = fs.createWriteStream('./big.file');
for(let i=0; i<= 1e6; i++) {
file.write('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n');
}
file.end();
Yukarıdaki kod, yaklaşık olarak 400 MB'lık bir dosya oluşturur. Oluşturduğumuz bu kodu daha sonra bir server oluşturup serve edelim:
import fs from "fs";
import http from "http";
const server = http.createServer();
server.on('request', (req, res) => {
fs.readFile('./big.file', (err, data) => {
if (err) throw err;
res.end(data);
});
});
server.listen(8000);
Ardından bu server'ı başlatalım ve curl
ile localhost:8000
adresine istek atalım:
curl localhost:8000
Bilgisayarımızın hafızası/belleği bu isteği atmadan önce normal boyutlardayken, bu isteği karşılamaya çalışırken oldukça yükseldiğini göreceksiniz. Bunun nedeni oldukça büyük olan big.file
dosyamızı tek seferde response olarak döndürmeye çalışmamızdır. Bu, kullanışsız bir yoldur. Bunun yerine Node'da bulunanfs
modülünün içerisinde yer alan createReadStream
fonksiyonunu kullanarak, bunu pipe
fonksiyonuyla birleştirebiliriz.
import fs from "fs";
import http from "http";
const server = http.createServer();
server.on('request', (req, res) => {
const src = fs.createReadStream('./big.file');
src.pipe(res);
});
server.listen(8000);
Bu kodu çalıştırdığımızda, belleğin yükselmediğini göreceksiniz. Çünkü pipe
fonksiyonu, backpressure
sorununu çözmek için kullanılır. Bu sayede bellek kullanımı da oldukça düşer. Tekrar curl
ile localhost:8000
adresine istek atalım:
curl localhost:8000
Bu sefer bellek kullanımının yükselmediğini göreceksiniz. Bunun sebebi tıpkı bir boru hattı döşüyor gibi, pipe
fonksiyonuyla stream
'lerin birbirine bağlanmasıdır. Bu sayede backpressure
sorunu çözülmüş olur. Yani, tek bir seferde tüm veriyi vermeye çalışmak yerine bir chunk
, yani bir parça halinde veri gönderilir. Bu sayede bellek kullanımı da oldukça düşer.