সরাসরি পর্বে যান
পর্ব ৩৭ · ৫৯-এর মধ্যে · মডিউল ৬

ফাইল I/O — Path, Files আর java.nio.file

File I/O with java.nio.file — Paths, Reading and Writing Text, Charsets, Large Files, CSV, Managing Folders and Safe Paths

Taught in Bangla, this lesson covers file I/O with java.nio.file: building and normalizing paths, reading and writing small files with Files, charsets, UTF-8 and mojibake with Bangla text, streaming large files with Files.lines and buffered readers, a district-wise sales report from a CSV, managing folders with glob and temp files, and guarding against path traversal.

এই পর্বে যা শিখবেন

  • Path দিয়ে ঠিকানা বানানো, জোড়া আর পরিষ্কার করা — relative, absolute, resolve, normalize
  • Files দিয়ে ছোট ফাইল পড়া-লেখা, শেষে যোগ করা, আর কোন ব্যর্থতায় কোন exception আসে
  • byte আর অক্ষরের পার্থক্য, UTF-8, mojibake আর বাংলা লেখা নিরাপদে রাখা
  • বড় ফাইল stream করে পড়া, buffer-এর কাজ, আর একটি CSV থেকে জেলাভিত্তিক বিক্রির হিসাব
  • ফোল্ডার সামলানো, glob দিয়ে খোঁজা, temp ফাইল, আর ব্যবহারকারীর দেওয়া path-এর নিরাপত্তা যাচাই

১ · প্রোগ্রাম থামলে ডেটা কোথায় যায় — ফাইল কেন দরকার

পর্ব ২২-এ আপনি “হিসাব খাতা” বানিয়েছিলেন — একটি CLI টুল, যেখানে প্রতিদিনের খরচ লেখা যায় আর মোট দেখা যায়। একটা সমস্যা তখনই চোখে পড়েছিল: প্রোগ্রাম বন্ধ করলে সব খরচ উধাও। পরদিন খুলে আবার শূন্য থেকে শুরু। কারণ, প্রোগ্রামের সব ভ্যারিয়েবল, array আর object থাকে RAM-এ। আর RAM হলো ক্ষণস্থায়ী মেমরি: প্রোগ্রাম থামলে JVM তার মেমরি অপারেটিং সিস্টেমকে ফেরত দেয়, আর বিদ্যুৎ গেলে তো কথাই নেই।

মুদি দোকানের মালিকের কথা ভাবুন। দিনের বেলা তিনি অনেক হিসাব মাথায় রাখেন — কে কত টাকা বাকি নিল, কোন মাল ফুরিয়ে আসছে। কিন্তু রাতে ঘুমানোর আগে সেগুলো লাল মলাটের বাকির খাতায় লিখে রাখেন, কারণ জানেন সকালে মাথার হিসাব ঝাপসা হয়ে যাবে। মাথার হিসাব হলো RAM-এর ভ্যারিয়েবল — দ্রুত, কিন্তু ক্ষণস্থায়ী। খাতা হলো ফাইল — একটু ধীর, কিন্তু টিকে থাকে, অন্য কেউও পড়তে পারে। ডেটা টিকিয়ে রাখার এই গুণকে বলে persistence.

ফাইল আসলে ডিস্কে (SSD বা হার্ডডিস্কে) রাখা কিছু byte-এর একটি নামযুক্ত সারি। অপারেটিং সিস্টেম এগুলো ফোল্ডারে সাজিয়ে রাখে, আর প্রতিটির একটি ঠিকানা থাকে, যেমন /home/rahim/khata.csv বা C:\Users\Rahim\khata.csv. প্রোগ্রাম ফাইলে সরাসরি হাত দিতে পারে না; প্রতিবার অপারেটিং সিস্টেমকে অনুরোধ করতে হয় — খোলো, লেখো, বন্ধ করো। Java-র ফাইল API এই অনুরোধগুলোর একটি নিরাপদ মোড়ক।

Java-র তিন প্রজন্মের ফাইল API

Java-তে ফাইল নিয়ে কাজের উপায় তিন ধাপে বদলেছে, আর ইন্টারনেটে (এবং AI-এর উত্তরে) তিনটিই দেখবেন:

java.io (১৯৯৬)

Java 1.0-এর File, FileReader, FileInputStream. স্তরে স্তরে মোড়ানো stream, আর File class ব্যর্থ হলে প্রায়ই শুধু false দেয়।

পুরোনো কোডে সর্বত্র

NIO (Java 1.4, ২০০২)

buffer, channel, charset — দ্রুত আর নিচু-স্তরের I/O, বিশেষত সার্ভারের জন্য। দৈনন্দিন ফাইলের কাজ তখনো File দিয়েই হতো।

লাইব্রেরি ও সার্ভারের ভেতরে

NIO.2 (Java 7, ২০১১)

java.nio.file: Path + Files. স্পষ্ট exception, symbolic link, ফাইলের বৈশিষ্ট্য, ফোল্ডার পাহারা। Java 8 আর 11-এ আরও সহজ method যোগ হয়েছে।

নতুন কোডে এটাই

এই পর্বের মূল মডেলটি এক লাইনে: Path হলো ঠিকানা লেখা চিরকুট, আর Files হলো সেই ঠিকানায় গিয়ে কাজ করে আসা পিয়ন। চিরকুট বানালেই বাড়ি তৈরি হয় না — Path.of("khata.csv") লিখলে কোনো ফাইল তৈরি হয় না, ফাইল আছে কি না তাও দেখা হয় না। কাজ শুরু হয় যখন আপনি চিরকুটটি Files-এর কোনো method-এ দেন। আর যেহেতু বাইরের জগতে অনেক কিছু ভুল হতে পারে — ফাইল নেই, অনুমতি নেই, ডিস্ক ভরা — প্রায় প্রতিটি Files method পর্ব ২৯-এর checked exception IOException ঘোষণা করে।

ফাইলের কাজের তিন স্তর

Your code talks to Files; Files asks the operating system; the OS talks to the disk

আপনার কোড     Path p = Path.of("data", "khata.csv");     ← শুধু ঠিকানা, ডিস্ক ছোঁয় না
                 List<String> lines = Files.readAllLines(p);  ← এখানে আসল কাজ
                        │
java.nio.file    ফাইল খোলা → byte পড়া → UTF-8 দিয়ে অক্ষর বানানো → লাইনে ভাগ → বন্ধ
                        │   (কোথাও ব্যর্থ হলে IOException-এর একটি subclass)
অপারেটিং সিস্টেম  open(), read(), close() — system call
                        │
ডিস্ক            byte: e0 a6 9a e0 a6 be e0 a6 b2 ...
ছবি ৩৬.১ — একটি readAllLines ডাকের পেছনে খোলা, পড়া, রূপান্তর আর বন্ধ করা — সবই Files আপনার হয়ে করে।
মূল ধারণা

ফাইলের কাজে তিনটি প্রশ্ন সবসময় মাথায় রাখুন: কোথায় (Path ঠিক আছে তো?), কীভাবে লেখা (কোন charset-এ byte অক্ষর হবে?), আর ভুল হলে কী (কোন exception, আর খোলা ফাইল বন্ধ হবে তো?)। এই পর্বের প্রতিটি অংশ এর কোনো একটির উত্তর।

২ · Path — ফাইলের ঠিকানা লেখা, জোড়া আর পরিষ্কার করা

Path.of (Java 11 থেকে; আগে একই কাজ করত Paths.get) একটি বা কয়েকটি টুকরো নিয়ে ঠিকানা বানায়: Path.of("data", "2026", "jan.csv") হয় data/2026/jan.csv. টুকরোগুলো আলাদা দিলে Java নিজেই চলতি অপারেটিং সিস্টেমের বিভাজক বসায় — Linux ও Mac-এ /, Windows-এ \. এজন্য হাতে "data\\" + name লিখে ঠিকানা জোড়া একটি ক্লাসিক ভুল: Windows-এ চলে, Linux সার্ভারে গিয়ে পুরো লেখাটি একটি অদ্ভুত নামের ফাইল হয়ে যায় (Wandbox-এর Linux-এ যাচাই করা: Path.of("C:\\data\\jan.csv").getFileName() পুরো লেখাটিই ফেরত দেয়)।

relative বনাম absolute

Absolute path শুরু হয় ফাইল-সিস্টেমের মূল থেকে — /home/rahim/khata.csv. Relative path শুরু হয় “এখান থেকে” — data/khata.csv. প্রশ্ন হলো, “এখান” মানে কোথায়? উত্তর: working directory, অর্থাৎ যে ফোল্ডার থেকে প্রোগ্রাম চালানো হয়েছে। IDE-তে চালালে সাধারণত প্রজেক্টের মূল ফোল্ডার; terminal-এ চালালে যেখানে দাঁড়িয়ে java লিখেছেন। একই প্রোগ্রাম দুই জায়গা থেকে চালালে একই relative path দুটি আলাদা ফাইল দেখাতে পারে — “আমার মেশিনে তো চলে!” সমস্যার এটি বড় উৎস। toAbsolutePath() দেখায় relative path-টি আসলে কোথায় গিয়ে দাঁড়াচ্ছে; ডিবাগ করার সময় এটি ছাপিয়ে দেখা খুব কাজের।

resolve আর normalize — ধাপে ধাপে

দুটি method প্রায় প্রতিদিন লাগবে। resolve(other) একটি ঠিকানার পেছনে আরেকটি জোড়ে — Path.of("data").resolve("jan.csv") হয় data/jan.csv. আর normalize() ঠিকানা থেকে অপ্রয়োজনীয় অংশ ছেঁটে ফেলে: . মানে “এই ফোল্ডারেই”, আর .. মানে “এক ধাপ ওপরে”। একটি এলোমেলো ঠিকানা দিয়ে দেখা যাক (প্রতিটি ফল Java 21-এ যাচাই করা; শেষ ধাপের absolute path শুধু উদাহরণ, কারণ সেটি নির্ভর করে কোথা থেকে চালানো হলো তার ওপর):

1Path p = Path.of("data/../reports/./jan.csv");2p.getFileName();   // শেষ টুকরো3p.getParent();     // শেষ টুকরো বাদে4Path n = p.normalize();5Path a = Path.of("archive").resolve(n);6a.toAbsolutePath();
Path-এর অবস্থা
টুকরোগুলো
টুকরোর সংখ্যা
isAbsolute()
  1. শুধু লেখা থেকে একটি Path. Path.of লেখাটিকে / ধরে পাঁচটি টুকরোয় ভাগ করল। ফাইলটি আছে কি না, data ফোল্ডার আছে কি না — কিছুই দেখা হয়নি। Path হলো চিরকুট, বাড়ি নয়।
  2. নাম আর মা-ফোল্ডার। getFileName() দেয় শেষ টুকরো jan.csv; getParent() দেয় বাকিটা — data/../reports/., অর্থাৎ পরিষ্কার না করা অবস্থায় যা লেখা আছে, তাই। Path.of("jan.csv").getParent() হলে ফল null — মা-ফোল্ডার লেখাই নেই।
  3. normalize, প্রথম কাজ: . বাদ। “এই ফোল্ডারেই” কথাটি কোনো তথ্য যোগ করে না, তাই . টুকরোটি সরানো হলো। টুকরো এখন চারটি।
  4. normalize, দ্বিতীয় কাজ: data/.. কাটাকাটি। “data-তে ঢোকো, তারপর এক ধাপ ওপরে ওঠো” মানে কোথাও না যাওয়া। ফল reports/jan.csv. সাবধান: normalize শুধু লেখার ওপর কাজ করে, ডিস্ক দেখে না; data যদি আসলে একটি symbolic link হয়, তাহলে ফল ভুল হতে পারে — সেক্ষেত্রে toRealPath(), যা ডিস্ক দেখে আর ফাইল না থাকলে exception দেয়।
  5. resolve — জোড়া। archive-এর পেছনে reports/jan.csv বসল: archive/reports/jan.csv. একটি ফাঁদ মনে রাখুন: যা জোড়া হচ্ছে সেটি যদি নিজেই absolute হয়, resolve আগেরটি ফেলে দিয়ে সেটিই ফেরত দেয় — Path.of("data").resolve("/etc/passwd") হয় /etc/passwd. অংশ ৮-এ এটাই নিরাপত্তার বড় প্রশ্ন হবে।
  6. toAbsolutePath — আসল ঠিকানা। working directory (ধরুন /home/rahim/app) সামনে বসিয়ে পূর্ণ ঠিকানা পাওয়া গেল। এই মান মেশিন আর চালানোর জায়গাভেদে বদলায়, তাই পরীক্ষার আউটপুটে বা লগের তুলনায় এটি ছাপাবেন না — শুধু ডিবাগের সময়।

পুরোনো File class — চিনে রাখুন, নতুন কোডে নয়

পুরোনো কোডে সবখানে দেখবেন new File("khata.csv"). java.io.File একসাথে ঠিকানা আর কাজ — দুটোই করত, আর এর নকশায় কয়েকটি বড় দুর্বলতা ছিল। file.delete() ব্যর্থ হলে শুধু false দেয় — ফাইল নেই, নাকি অনুমতি নেই, নাকি অন্য প্রোগ্রাম খুলে রেখেছে, জানার উপায় নেই। listFiles() ব্যর্থ হলে দেয় null, যা পরের লাইনে NullPointerException হয়ে ফাটে। তুলনায় Files.delete(path) ব্যর্থ হলে নির্দিষ্ট exception ছোড়ে, যার নাম দেখেই কারণ বোঝা যায়। পুরোনো API থেকে File পেলে file.toPath(); উল্টো দিকে path.toFile().

৩ · ছোট ফাইল পড়া-লেখা — readString, writeString, readAllLines

কয়েক কিলোবাইট থেকে কয়েক মেগাবাইটের ফাইল — config, নোট, ছোট CSV — পুরোটা একবারে মেমরিতে আনা সবচেয়ে সহজ। এর জন্য চারটি method:

methodকাজকবে থেকে
Files.readString(p)পুরো ফাইল একটি String-এJava 11
Files.writeString(p, text)একটি String ফাইলে লেখাJava 11
Files.readAllLines(p)প্রতিটি লাইন আলাদা উপাদান — List<String>Java 7 (charset সহ), Java 8 (charset ছাড়া)
Files.write(p, lines)লাইনের তালিকা লেখা, প্রতিটির পরে লাইন-বিরতিJava 7 / 8

প্রতিটিই নিজে ফাইল খোলে আর কাজ শেষে বন্ধ করে, তাই আপনাকে close নিয়ে ভাবতে হয় না। লেখার method-গুলো ডিফল্টে ফাইল না থাকলে বানায়, আর থাকলে আগের সব লেখা মুছে নতুন করে লেখে। শেষে যোগ করতে চাইলে বলতে হয় — একটু পরে দেখবেন। কিন্তু প্রথমে compiler-এর একটি আপত্তি:

import java.nio.file.*;

class Main {
    public static void main(String[] args) {
        String text = Files.readString(Path.of("khata.csv"));
        System.out.println(text);
    }
}
Main.java:5: error: unreported exception IOException; must be caught or declared to be thrown
        String text = Files.readString(Path.of("khata.csv"));
                                      ^
1 error

পর্ব ২৯-এর সেই পাহারাদার। readString ঘোষণা করে throws IOException, তাই আপনাকে হয় try-catch লিখতে হবে, নয়তো নিজের method-এ throws IOException ঘোষণা করতে হবে। ছোট উদাহরণে main-এ throws IOException লেখা চলে; আসল অ্যাপে ব্যবহারকারীকে বোঝার মতো বার্তা দেখাতে ধরুন। throws দিয়ে চালালে, আর ফাইলটি না থাকলে, এই trace আসে (ভেতরের JDK-লাইনগুলো সংক্ষেপ করা):

Exception in thread "main" java.nio.file.NoSuchFileException: khata.csv
	at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
	... (আরও ৭টি JDK-র ভেতরের লাইন)
	at java.base/java.nio.file.Files.readString(Files.java:3305)
	at Main.main(Main.java:6)

NoSuchFileException-এর বার্তায় শুধু path — khata.csv. ওপরের UnixException লাইন বলছে এটি Linux-এ (বা Mac-এ) চালানো; Windows-এ নাম আলাদা হবে, কিন্তু exception একই। পুরোনো new FileReader("nai.txt") দেয় java.io.FileNotFoundException: nai.txt (No such file or directory) — “নেই” আর “অনুমতি নেই” দুটোতেই একই exception-টাইপ, পার্থক্য শুধু বার্তার লেখায়।

হিসাব খাতা সেভ আর আবার লোড

এবার পর্ব ২২-এর খাতার একটি সরল রূপকে ফাইলে টিকিয়ে রাখা যাক। প্রতিটি খরচ একটি record (পর্ব ২৫); ফাইলে প্রতিটি খরচ এক লাইন, ঘরগুলো কমা দিয়ে আলাদা — এই ফরম্যাটের নাম CSV. প্রোগ্রামটি দুটি খরচ লেখে, তৃতীয়টি শেষে যোগ করে, তারপর ফাইল থেকে পড়ে record-এ ফেরত আনে, যেন পরদিন অ্যাপ খুলেছেন:

import java.io.IOException;
import java.nio.file.*;
import java.util.List;

record Expense(String date, String item, int taka) {
    String toCsv() { return date + "," + item + "," + taka; }
    static Expense fromCsv(String line) {
        String[] p = line.split(",");
        return new Expense(p[0], p[1], Integer.parseInt(p[2]));
    }
}
class Main {
    public static void main(String[] args) throws IOException {
        Path file = Path.of("khata.csv");
        List<Expense> today = List.of(new Expense("2026-10-18", "চাল", 850),
                                      new Expense("2026-10-18", "রিকশা ভাড়া", 60));
        Files.write(file, today.stream().map(Expense::toCsv).toList());
        Files.writeString(file, "2026-10-19,বিদ্যুৎ বিল,1200\n", StandardOpenOption.APPEND);
        List<Expense> loaded = Files.readAllLines(file).stream().map(Expense::fromCsv).toList();
        loaded.forEach(e -> System.out.println(e.item() + " → " + e.taka() + " টাকা"));
        int total = loaded.stream().mapToInt(Expense::taka).sum();
        System.out.println("মোট " + loaded.size() + "টি খরচ, " + total + " টাকা");
        Files.delete(file);
    }
}

আউটপুট চার লাইন: চাল → 850 টাকা, রিকশা ভাড়া → 60 টাকা, বিদ্যুৎ বিল → 1200 টাকা, আর মোট 3টি খরচ, 2110 টাকা. যাত্রাটা দেখুন: record → toCsv → লাইন → ফাইলের byte; তারপর উল্টো পথে byte → লাইন → split → parseInt → record. তৃতীয় খরচটি StandardOpenOption.APPEND দিয়ে শেষে যোগ হয়েছে; এটি না দিলে writeString আগের দুই লাইন মুছে দিত (অনুশীলন ১)। বাংলা লেখা ঠিকঠাক ফিরে এসেছে, কারণ লেখা আর পড়া দুটিই UTF-8-এ — অংশ ৪-এ এর গুরুত্ব। শেষে Files.delete দিয়ে ফাইলটি মুছে ফেলা হয়েছে, যাতে অনলাইন runner পরিষ্কার থাকে। (অনলাইন runner-এর জন্য কোনো class public নয়; নিজের প্রজেক্টে public class Main থাকে Main.java-তে।)

খোলার ধরন — StandardOpenOption

লেখার method-গুলোর শেষে কয়েকটি StandardOpenOption দেওয়া যায়, যা বলে ফাইল কীভাবে খোলা হবে। কিছু না দিলে ডিফল্ট হলো CREATE, TRUNCATE_EXISTING আর WRITE — অর্থাৎ “না থাকলে বানাও, থাকলে খালি করে লেখো”।

optionফাইল আছেফাইল নেই
(কিছু না দিলে)খালি করে নতুন লেখানতুন ফাইল বানায়
APPENDশেষে যোগNoSuchFileException
CREATE, APPENDশেষে যোগনতুন ফাইল বানায় — লগ ফাইলের জন্য আদর্শ
CREATE_NEWFileAlreadyExistsExceptionনতুন ফাইল বানায় — ভুল করে মুছে ফেলা এড়াতে

শুধু APPEND দিলে আর ফাইল না থাকলে সত্যিই ব্যর্থ হয় — যাচাই করা বার্তা java.nio.file.NoSuchFileException: log.txt. তাই একটি দোকানের অর্ডার-লগ, যেখানে প্রতিটি অর্ডার এক লাইন হিসেবে জমতে থাকবে, এভাবে লিখুন:

static void log(String message) throws IOException {
    Files.writeString(Path.of("orders.log"), message + System.lineSeparator(),
                      StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}

প্রথম ডাকে ফাইল তৈরি হয়, পরের প্রতিটি ডাক শেষে একটি লাইন জোড়ে। System.lineSeparator() চলতি অপারেটিং সিস্টেমের line separator দেয়; পড়ার সময় readAllLines দুই রকম বিরতিই চেনে (Windows-এর \r\n লেখা ফাইল পড়েও পরিষ্কার লাইন দেয় — যাচাই করা)।

৪ · charset — ফাইলে byte থাকে, অক্ষর নয়

এই অংশটি বাংলায় লেখেন এমন প্রত্যেকের জন্য সবচেয়ে জরুরি। ডিস্ক অক্ষর চেনে না; চেনে শুধু byte, অর্থাৎ ০ থেকে ২৫৫-এর সংখ্যা। “আমার” লিখে সেভ করলে ফাইলে যায় এই ১২টি byte: e0 a6 86 e0 a6 ae e0 a6 be e0 a6 b0. চারটি অক্ষর, ১২টি byte — কারণ UTF-8-এ প্রতিটি বাংলা অক্ষর তিন byte নেয়। অক্ষরকে byte বানানোর আর byte থেকে অক্ষর ফেরত আনার এই নিয়মের নাম charset (বা character encoding). Java 21-এ যাচাই করা: “আমার সোনার বাংলা” ১৬টি char, কিন্তু UTF-8-এ ৪৪ byte (দুটি space এক byte করে)।

সমস্যা হয় যখন লেখার charset আর পড়ার charset আলাদা। তখন byte একই থাকে, কিন্তু ভুল নিয়মে অনুবাদ হয়ে অর্থহীন অক্ষর বেরোয়। এই হিজিবিজির নাম mojibake:

একই ১২টি byte, দুই রকম পড়া

The bytes never change — only the rule used to turn them back into characters

ফাইলে byte:        e0 a6 86 | e0 a6 ae | e0 a6 be | e0 a6 b0

UTF-8 হিসেবে পড়া:   আ          ম          া          র        → আমার            ✓
Windows-1252 হিসেবে: à ¦ †      à ¦ ®      à ¦ ¾      à ¦ °    → আমার   ✗ mojibake
ছবি ৩৬.২ — UTF-8-এ তিন byte মিলে একটি বাংলা অক্ষর; Windows-1252 প্রতিটি byte-কে আলাদা ইউরোপীয় অক্ষর ভাবে।

আর কখনো কখনো ভুল charset-এ পড়া একেবারে ব্যর্থ হয়। UTF-8-এর নিয়ম কঠোর — কিছু byte-এর সারি বৈধই নয়। পুরোনো ANSI ফাইল (যেমন বিজয়ের মতো ফন্ট-নির্ভর পুরোনো বাংলা লেখা, যা Unicode নয়) UTF-8 ধরে পড়লে Files.readAllLines দেয় java.nio.charset.MalformedInputException: Input length = 1 — একটি MalformedInputException. চুপচাপ হিজিবিজির চেয়ে এটি ভালো।

Java-তে ডিফল্ট charset কোনটি

এখানে একটি গুরুত্বপূর্ণ ইতিহাস আছে, যা পুরোনো কোড পড়তে লাগবে:

  • Files-এর method (readString, readAllLines, lines, newBufferedReader, write…): charset ছাড়া রূপগুলো (Java 8 থেকে) সবসময় UTF-8; Java 7-এ charset দেওয়া বাধ্যতামূলক ছিল।
  • পুরোনো java.io class (FileReader, FileWriter, InputStreamReader, PrintWriter): Java 17 পর্যন্ত charset না দিলে অপারেটিং সিস্টেমের ডিফল্ট — ইংরেজি Windows-এ সাধারণত Windows-1252, Linux-এ সাধারণত UTF-8. ফলে একই প্রোগ্রাম Linux-এ বাংলা ঠিক রাখত, Windows-এ নষ্ট করত।
  • Java 18 থেকে (JEP 400): সব জায়গায় ডিফল্ট charset UTF-8, অপারেটিং সিস্টেম যা-ই হোক। (Wandbox-এর Java 21-এ Charset.defaultCharset() ছাপলে আসে UTF-8.) কনসোলে ছাপার encoding অবশ্য আলাদা বিষয় — সেটি এখনো টার্মিনালের ওপর নির্ভর করে।

Java 21-এ ডিফল্ট UTF-8 বলে charset লেখা বাদ দেবেন না, যদি ফাইলটি অন্য কোথাও থেকে আসে। ব্যাংকের পুরোনো সফটওয়্যার থেকে নামানো CSV বা Excel-এর “CSV” সেভ অনেক সময় UTF-8 নয়। নিয়ম: বাইরের ফাইলের charset জেনে নিন, আর কোডে স্পষ্ট লিখুন — Files.readString(p, StandardCharsets.UTF_8) বা দরকারে Charset.forName("windows-1252"). স্পষ্ট লেখা কোড পরের পাঠককেও বলে দেয়, এখানে charset নিয়ে ভাবা হয়েছে।

৫ · বড় ফাইল — Files.lines, buffer আর বন্ধ করার দায়িত্ব

একটি ই-কমার্স সাইটের এক মাসের লগ ফাইল কয়েক গিগাবাইট হতে পারে। readAllLines পুরো ফাইল মেমরিতে আনে — প্রতিটি লাইন একটি String object, আর বাংলা বা ইংরেজি যা-ই হোক, মেমরিতে ফাইলের চেয়ে বেশি জায়গা লাগতে পারে। JVM-এর heap ছোট হলে প্রোগ্রাম OutOfMemoryError দিয়ে থেমে যাবে। বড় ফাইলের নিয়ম হলো: একবারে একটি লাইন — পড়ো, কাজ করো, ভুলে যাও।

এর আধুনিক পথ Files.lines(p) (Java 8): এটি একটি Stream<String> দেয় (পর্ব ৩৫)। Stream অলস — লাইন পড়া হয় তখনই, যখন পাইপের শেষ ধাপ চাইবে। তাই কয়েক গিগাবাইটের ফাইলেও মেমরিতে থাকে কয়েকটি লাইন মাত্র। কিন্তু একটি বড় পার্থক্য আছে: সাধারণ list.stream()-এর পেছনে থাকে মেমরির একটি তালিকা, আর Files.lines-এর পেছনে থাকে একটি খোলা ফাইল. অপারেটিং সিস্টেম প্রতিটি খোলা ফাইলের জন্য একটি file handle দেয়, আর এর সংখ্যা সীমিত। Stream শেষ হলেও ফাইল নিজে নিজে বন্ধ হয় না। তাই Files.lines সবসময় try-with-resources-এ (পর্ব ২৯):

try (Stream<String> lines = Files.lines(Path.of("orders.log"))) {
    long errors = lines.filter(l -> l.contains("ERROR")).count();
    System.out.println("ভুল: " + errors);
}   // এখানে lines.close() — ফাইল বন্ধ

বন্ধ না করলে কী হয়, তা Wandbox-এ যাচাই করা: একটি লুপে বারবার Files.lines খুলে বন্ধ না করায় একসময় এল java.nio.file.FileSystemException: log.txt: Too many open files. সার্ভারে এর মানে নতুন ফাইল তো বটেই, নতুন নেটওয়ার্ক সংযোগও আর খোলা যায় না, কারণ Linux-এ সেগুলোও file handle. Files.list আর Files.walk-এর জন্যও একই নিয়ম।

BufferedReader আর BufferedWriter

লাইন ধরে পড়ার পুরোনো ও এখনো জনপ্রিয় পথ BufferedReader — Files.newBufferedReader(p) দিয়ে পাওয়া যায়, আর readLine() প্রতিবার একটি লাইন দেয়, শেষে null. লেখার জোড়া BufferedWriter — Files.newBufferedWriter(p, options…). নামের “Buffered” অংশটাই আসল কথা। প্রতিটি write ডাকে সরাসরি ডিস্কে লিখলে প্রতিবার অপারেটিং সিস্টেমকে ডাকতে হতো — একটি system call, যা সাধারণ method ডাকের চেয়ে বহুগুণ দামি। Buffer মেমরিতে লেখা জমায় (BufferedWriter-এর ডিফল্ট ৮১৯২ অক্ষর), ভরলে বা বন্ধ করার সময় একবারে পাঠায়। ফলে লক্ষ লাইন লিখতে লক্ষ বার নয়, কয়েকশো বার অপারেটিং সিস্টেমকে ডাকতে হয়।

এর একটি পরিণতি নতুনদের প্রায়ই অবাক করে: write ডাকার পরেও লেখা ফাইলে নাও পৌঁছাতে পারে। যাচাই করা: newBufferedWriter দিয়ে একটি লাইন লিখে বন্ধ করার আগে Files.size দেখালে 0 byte; close()-এর পরে লাইনটি ফাইলে আছে। ধাপে ধাপে দেখুন — একটি দোকানের অর্ডার-লগে দুটি লাইন যোগ:

1try (BufferedWriter w = Files.newBufferedWriter(log,2        StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {3    w.write("10:00 অর্ডার #101\n");4    w.write(nextOrderLine());   // #1025}   // Java নিজে w.close() ডাকে6// try ছাড়া, close() ছাড়া?
কোথায় কী আছে
ফাইল খোলা?
buffer (মেমরি)
ডিস্কে orders.log
  1. খোলা। CREATE, APPEND দিয়ে লগ ফাইল খোলা হলো; অপারেটিং সিস্টেম একটি file handle দিল। ফাইলে আগে থেকে একটি লাইন আছে — #100.
  2. প্রথম write — শুধু মেমরিতে। লাইনটি buffer-এ জমল। ডিস্কে কিছুই বদলায়নি। এই মুহূর্তে বিদ্যুৎ গেলে #101 হারাবে।
  3. দ্বিতীয় write. buffer-এ এখন দুই লাইন। লক্ষ করুন, এখনো একবারও অপারেটিং সিস্টেমকে ডাকা হয়নি — এটাই buffer-এর সাশ্রয়।
  4. block শেষ — close(). try-with-resources নিজে w.close() ডাকল। close প্রথমে flush করে (buffer খালি করে ডিস্কে পাঠায়), তারপর handle ফেরত দেয়। এখন ফাইলে তিন লাইন।
  5. মাঝপথে exception হলেও। ধরুন দ্বিতীয় লাইন বানাতে গিয়ে exception হলো। try-with-resources তবু close() ডাকে — যা লেখা হয়েছিল (#101) তা ডিস্কে যায়, handle ফেরত আসে, তারপর exception ওপরে ওঠে। পর্ব ২৯-এ শেখা AutoCloseable-এর পুরো সুবিধা এখানে।
  6. try আর close ছাড়া। BufferedWriter w = Files.newBufferedWriter(...) লিখে বন্ধ না করলে buffer-এর লেখা কোনোদিন ডিস্কে নাও যেতে পারে, আর handle আটকে থাকে। প্রোগ্রাম চলতে চলতে লেখা “হারিয়ে যাওয়ার” সবচেয়ে সাধারণ কারণ এটাই।
flush মানেই কি ডিস্কে নিরাপদ?

পুরোপুরি নয়। flush লেখা পাঠায় অপারেটিং সিস্টেমের কাছে, যে নিজেও কিছুক্ষণ মেমরিতে জমিয়ে রাখতে পারে; তখন বিদ্যুৎ গেলে শেষ লেখা হারাতে পারে। লেনদেনের মতো জায়গায় তাই StandardOpenOption.SYNC বা FileChannel.force(true) — ধীর, কিন্তু ডিস্কে পৌঁছানো নিশ্চিত।

byte stream, character stream আর মোড়কের শিকল

পুরোনো java.io-তে দুই পরিবার: byte stream (InputStream/OutputStream) কাঁচা byte নিয়ে কাজ করে — ছবি, PDF, zip-এর জন্য; আর character stream (Reader/Writer) অক্ষর নিয়ে, ভেতরে একটি charset দিয়ে byte-কে অক্ষর বানিয়ে। এগুলো জোড়া লাগে decorator pattern-এ — প্রতিটি স্তর আগের স্তরকে মুড়ে একটি ক্ষমতা যোগ করে (পর্ব ৯-এ System.in-এর ওপর এমন শিকল দেখেছেন; নকশাটির বিস্তারিত পর্ব ৪৮-এ):

// java.io — তিন স্তরের মোড়ক: byte → অক্ষর (UTF-8) → buffer আর readLine
BufferedReader in = new BufferedReader(
        new InputStreamReader(
                new FileInputStream("khata.csv"), StandardCharsets.UTF_8));

// java.nio.file — একই শিকল, এক ডাকে
BufferedReader in2 = Files.newBufferedReader(Path.of("khata.csv"));

দুটি প্রায় এক, তবে একটি সূক্ষ্ম পার্থক্য আছে: InputStreamReader অবৈধ byte পেলে চুপচাপ � চিহ্ন বসিয়ে দেয়, আর Files.newBufferedReader MalformedInputException ছোড়ে। ডেটার সঠিকতা জরুরি হলে দ্বিতীয়টিই ভালো। ছবি বা অন্য binary ফাইলের জন্য Files.readAllBytes, Files.newInputStream আর Files.newOutputStream আছে — সেখানে charset-এর প্রশ্নই নেই।

৬ · একটি CSV থেকে রিপোর্ট — জেলাভিত্তিক বিক্রি

একটি পরিবেশক কোম্পানির বিক্রির CSV — প্রতিটি লাইনে জেলা, পণ্য আর টাকা — থেকে জানতে হবে কোন জেলায় মোট কত বিক্রি। পর্ব ৩৫-এর groupingBy আর Files.lines মিলে কাজটি কয়েক লাইনের। প্রথমে ধাপগুলো:

1try (Stream<String> lines = Files.lines(csv)) {2    byDistrict = lines.skip(1)3        .map(line -> line.split(","))4        .collect(groupingBy(p -> p[0], TreeMap::new,5                 summingInt(p -> Integer.parseInt(p[2]))));6}
প্রতিটি ধাপে
লাইন
p (split-এর ফল)
Map
  1. শিরোনাম বাদ। CSV-র প্রথম লাইনে সাধারণত কলামের নাম থাকে। skip(1) সেটি ফেলে দেয় — নইলে parseInt("taka") ব্যর্থ হতো।
  2. split — লাইন থেকে ঘর। "ঢাকা,চা,1200" কমা ধরে ভাঙল: p[0] জেলা, p[1] পণ্য, p[2] টাকা (এখনো লেখা, সংখ্যা নয়)।
  3. গ্রুপে জমা। groupingBy চাবি বের করল (p[0] = ঢাকা), আর summingInt সেই চাবির যোগফলে parseInt(p[2]) যোগ করল। চাবিটি নতুন, তাই শুরু শূন্য থেকে।
  4. পরের লাইন — নতুন জেলা। রাজশাহীর জন্য নতুন চাবি তৈরি হলো। লক্ষ করুন, প্রতিটি লাইন পুরো পাইপ পেরিয়ে তবে পরের লাইন পড়া হচ্ছে — পর্ব ৩৫-এর অলসতা। মেমরিতে একসাথে একটির বেশি লাইন থাকছে না।
  5. পুরোনো চাবিতে যোগ। আবার ঢাকা: ১২০০ + ৮০০ = ২০০০. পুরো হিসাব শেষ পর্যন্ত একটি ছোট Map — ফাইল যত বড়ই হোক, Map-এর আকার শুধু জেলার সংখ্যার সমান।
  6. শেষ দুই লাইন। খুলনা নতুন চাবি (২৫০০), রাজশাহী ৩০০০ + ৪০০ = ৩৪০০.
  7. ফাইল শেষ, ফাইল বন্ধ। collect শেষ হলো, try block শেষ হলো, stream-এর close() ফাইল বন্ধ করল। TreeMap চাবিগুলো সাজিয়ে রাখে, তাই ছাপার ক্রম প্রতিবার একই।

এবার পুরোটা চালিয়ে দেখুন। প্রোগ্রামটি নিজেই BufferedWriter দিয়ে নমুনা ফাইল বানায়, তারপর পড়ে হিসাব করে, শেষে মুছে ফেলে:

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;

class Main {
    public static void main(String[] args) throws IOException {
        Path csv = Path.of("sales.csv");
        try (BufferedWriter w = Files.newBufferedWriter(csv)) {
            w.write("district,product,taka\n");
            w.write("ঢাকা,চা,1200\nরাজশাহী,আম,3000\nঢাকা,বিস্কুট,800\n");
            w.write("খুলনা,চিংড়ি,2500\nরাজশাহী,চা,400\n");
        }
        Map<String, Integer> byDistrict;
        try (Stream<String> lines = Files.lines(csv)) {
            byDistrict = lines.skip(1)
                .map(line -> line.split(","))
                .collect(Collectors.groupingBy(p -> p[0], TreeMap::new,
                         Collectors.summingInt(p -> Integer.parseInt(p[2]))));
        }
        byDistrict.forEach((d, t) -> System.out.println(d + ": " + t + " টাকা"));
        Files.delete(csv);
    }
}

আউটপুট তিন লাইন: খুলনা: 2500 টাকা, ঢাকা: 2000 টাকা, রাজশাহী: 3400 টাকা. ক্রমটি বর্ণানুক্রমিক মনে হলেও আসলে Unicode code point-এর ক্রম (খ, ঢ, র) — বাংলা অভিধানের সঠিক ক্রম চাইলে পর্ব ৩৩-এর Collator. দুটি try-with-resources আছে: প্রথমটির শেষে BufferedWriter বন্ধ হয়ে লেখা ডিস্কে পৌঁছায় — এর আগে পড়তে গেলে ফাইল ফাঁকা পেতেন; দ্বিতীয়টির শেষে পড়ার ফাইল বন্ধ হয়। byDistrict বাইরে ঘোষণা করা, যাতে ফাইল বন্ধের পরেও ফল ব্যবহার করা যায় — খোলা ফাইল যত কম সময় ধরে রাখা যায়, তত ভালো।

split(",")-এর সীমা — আসল CSV এত সরল নয়

উদাহরণের CSV পরিষ্কার, কিন্তু Excel থেকে আসা আসল CSV-তে তিনটি ফাঁদ প্রায়ই থাকে। প্রথমত, ঘরের ভেতরেই কমা: ঢাকা,"চা, বিস্কুট",500. CSV-র নিয়মে উদ্ধৃতি-চিহ্নের ভেতরের কমা আলাদা ঘর নয়, কিন্তু split(",") সেটা জানে না — চারটি টুকরো দেয়, আর parseInt(p[2]) ভেঙে পড়ে java.lang.NumberFormatException: For input string: " বিস্কুট"" দিয়ে (যাচাই করা)। দ্বিতীয়ত, শেষের ফাঁকা ঘর: "ঢাকা,চা,".split(",") দুটি টুকরো দেয়, তিনটি নয় — Java শেষের ফাঁকা টুকরো ফেলে দেয়, ফলে p[2]-এ ArrayIndexOutOfBoundsException: Index 2 out of bounds for length 2. সমাধান split(",", -1), যা ফাঁকা টুকরোও রাখে। তৃতীয়ত, Excel-এর সেভ করা CSV-র charset আর শুরুর অদৃশ্য BOM চিহ্ন।

নিজের বানানো সরল ফাইলে split যথেষ্ট। বাইরের CSV-র জন্য পরীক্ষিত লাইব্রেরি — যেমন Apache Commons CSV বা OpenCSV (JDK-র অংশ নয়; পর্ব ৪৬-এ Maven দিয়ে লাইব্রেরি যোগ করা শিখবেন)। আর একটি অবাক করা তথ্য: Integer.parseInt("১০০") সত্যিই ১০০ দেয় — Java যেকোনো Unicode অঙ্ক চেনে (যাচাই করা)।

৭ · ফাইল আর ফোল্ডার সামলানো — খোঁজা, কপি, সরানো, মোছা

পড়া-লেখার বাইরেও Files অনেক কাজ করে। সবচেয়ে বেশি লাগে এগুলো:

methodকাজমনে রাখার কথা
exists, isDirectory, isRegularFileআছে কি না, ফোল্ডার না ফাইলউত্তর শুধু এই মুহূর্তের — পরের লাইনে বদলে যেতে পারে
size, getLastModifiedTimeআকার (byte), শেষ বদলের সময়বাংলা লেখার byte অক্ষরের তিনগুণ
createDirectoriesপুরো ফোল্ডার-পথ বানানোআগে থেকে থাকলে কিছু করে না; createDirectory থাকলে ব্যর্থ হয়
copy(src, dst, …)কপিগন্তব্য থাকলে ব্যর্থ, যদি না REPLACE_EXISTING
move(src, dst, …)সরানো বা নাম বদলএকই নিয়ম; ATOMIC_MOVE এক ধাপে সরায়
delete / deleteIfExistsমোছাপ্রথমটি না থাকলে exception; দ্বিতীয়টি false দেয়

কপি আর সরানোর আচরণ বদলানো হয় StandardCopyOption দিয়ে: Files.copy(a, b, StandardCopyOption.REPLACE_EXISTING). প্রতিটি ব্যর্থতার নিজস্ব exception, সবই IOException-এর subclass. নিচের বার্তাগুলো Java 21-এ চালিয়ে পাওয়া:

যা করা হলোযা এল
নেই এমন ফাইল পড়া বা deletejava.nio.file.NoSuchFileException: nai.txt
নেই এমন ফোল্ডারের ভেতরে লেখাNoSuchFileException: nodir/x.txt
গন্তব্য আছে, তবু option ছাড়া copy/moveFileAlreadyExistsException: b.txt
ভেতরে ফাইল আছে এমন ফোল্ডার deleteDirectoryNotEmptyException: dx
নেই এমন ফাইলে deleteIfExistsকোনো exception নয়, ফল false

if (Files.exists(p)) Files.delete(p); লেখার প্রবৃত্তি স্বাভাবিক, কিন্তু দুই লাইনের মাঝে অন্য প্রোগ্রাম ফাইলটি মুছে ফেলতে পারে — তখন delete তবু ব্যর্থ হবে। “যাচাই করে তারপর কাজ” প্যাটার্নের এই ফাঁকের নাম TOCTOU (time-of-check to time-of-use). ভালো পথ: সরাসরি কাজটি করুন আর exception সামলান, অথবা যে method নিজেই দুটো একসাথে করে সেটি নিন — deleteIfExists, createDirectories, CREATE_NEW.

নিরাপদে সেভ — temp ফাইলে লিখে এক ধাপে সরানো

হিসাব খাতা সেভ করার মাঝপথে প্রোগ্রাম crash করলে কী হবে? writeString আগে ফাইল খালি করে, তারপর লেখে — মাঝপথে থামলে পুরোনো আর নতুন, কোনো খাতাই থাকবে না। পেশাদার সফটওয়্যার তাই একই ফোল্ডারে একটি অস্থায়ী ফাইলে পুরো লেখা শেষ করে, তারপর Files.move(tmp, khata, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) দিয়ে এক ধাপে জায়গা বদল করে। অপারেটিং সিস্টেম নিশ্চয়তা দেয়, অন্য কেউ হয় পুরো পুরোনো ফাইল দেখবে, নয়তো পুরো নতুন — অর্ধেক লেখা কখনো নয়। (সব ফাইল-সিস্টেম ATOMIC_MOVE সমর্থন করে না; না পারলে exception আসে।)

সাময়িক কাজের জন্য Files.createTempFile("report", ".csv") অপারেটিং সিস্টেমের temp ফোল্ডারে অনন্য নামে একটি temporary file বানায়, আর Files.createTempDirectory একটি ফোল্ডার। নামটি এলোমেলো, আর কাজ শেষে মোছা আপনার দায়িত্ব।

ফোল্ডারের তালিকা — list, walk আর glob

একটি ফোল্ডারের সরাসরি ভেতরের জিনিস চাইলে Files.list(dir); ভেতরের ফোল্ডারের ভেতরেও, পুরো গাছ ধরে, চাইলে Files.walk(dir). দুটিই Stream<Path> দেয়, আর দুটির পেছনেই খোলা ফোল্ডার — তাই try-with-resources. আরও একটি কথা: ক্রমের কোনো নিশ্চয়তা নেই, অপারেটিং সিস্টেম যেভাবে দেয়। নিয়মিত ক্রম চাইলে sorted().

নাম ধরে ছাঁকতে আছে glob — terminal-এর *.csv-এর মতো সহজ নকশা। Files.newDirectoryStream(dir, "*.csv") একটি DirectoryStream দেয়, যা for-each দিয়ে ঘোরা যায়; পুরো গাছে খুঁজতে Files.walk-এর সাথে একটি PathMatcher:

// শুধু inbox-এর সরাসরি ভেতরের .csv
try (DirectoryStream<Path> ds = Files.newDirectoryStream(inbox, "*.csv")) {
    for (Path p : ds) System.out.println(p.getFileName());
}
// পুরো গাছে, ভেতরের ফোল্ডারসহ — ** ফোল্ডার পার হয়
PathMatcher csv = FileSystems.getDefault().getPathMatcher("glob:**.csv");
try (Stream<Path> all = Files.walk(inbox)) {
    all.filter(csv::matches).sorted().forEach(System.out::println);
}

যাচাই করা ফল: inbox-এ a.csv, b.CSV, c.csv, photo.jpg, notes.txt আর old/d.csv থাকলে প্রথম অংশ দেয় a.csv আর c.csv — b.CSV নয়, কারণ Linux-এ বড়-ছোট হাতের অক্ষর আলাদা ধরা হয় (Windows-এ সাধারণত ধরা হয় না)। দ্বিতীয় অংশ old/d.csv-ও পায়। আর একটি সূক্ষ্মতা: glob:*.{csv,txt} মেলে x.txt-এর সাথে, কিন্তু inbox/x.txt-এর সাথে নয়, কারণ * ফোল্ডারের / পার হয় না। তাই নাম মেলাতে প্রায়ই matcher.matches(p.getFileName()) লেখা হয়।

একটি বাস্তব কাজ: Downloads ফোল্ডার গোছানো

একটি টুল, যা Downloads-এর .jpg ফাইল images-এ আর .pdf ফাইল documents-এ সরাবে: Files.list দিয়ে ওপরের স্তরের ফাইল, isRegularFile দিয়ে ফোল্ডার বাদ, নামের শেষ থেকে extension, createDirectories দিয়ে লক্ষ্য-ফোল্ডার, তারপর Files.move. একই নামের ফাইল আগে থেকে থাকলে কী করবেন — সেটাই আসল নকশার প্রশ্ন (অনুশীলন ৪)।

ফাইলের বৈশিষ্ট্য আর ফোল্ডার পাহারা

ফাইলের বিষয়বস্তুর বাইরের তথ্য — আকার, তৈরি ও বদলের সময়, মালিক, অনুমতি — হলো file attributes. Files.readAttributes(p, BasicFileAttributes.class) একবারে অনেকগুলো দেয়। Linux আর Mac-এ Files.setPosixFilePermissions(p, PosixFilePermissions.fromString("rw-------")) দিয়ে ঠিক করা যায় শুধু মালিক পড়তে-লিখতে পারবেন — গোপন key বা password-এর ফাইলের জন্য জরুরি। Windows-এ POSIX অনুমতি নেই, সেখানে এই method UnsupportedOperationException দেয়।

আর যদি জানতে চান কোনো ফোল্ডারে নতুন ফাইল এলো কি না — যেমন অফিসের স্ক্যানার যে ফোল্ডারে PDF রাখে — তাহলে বারবার ফোল্ডার ঘেঁটে দেখার দরকার নেই। Java 7-এর WatchService একটি ফোল্ডার নিবন্ধন করে রাখে আর তৈরি, বদল বা মোছার ঘটনা জানায়; Linux-এ এটি অপারেটিং সিস্টেমের নিজস্ব ব্যবস্থা (inotify) ব্যবহার করে। সীমা দুটি: শুধু নিবন্ধিত ফোল্ডার দেখে, ভেতরেরগুলো নয়; আর কিছু প্ল্যাটফর্মে ঘটনা জানাতে কয়েক সেকেন্ড দেরি হয়।

৮ · নিরাপত্তা — ব্যবহারকারীর দেওয়া নামে “../” থাকলে

ধরুন একটি অনলাইন দোকানের সার্ভার, যেখানে গ্রাহকেরা নিজেদের রসিদ নামাতে পারেন: /download?file=receipt-1042.pdf. কোড সহজ — uploads ফোল্ডারের সাথে নামটি জুড়ে ফাইল পাঠানো। এখন কেউ যদি নাম দেন ../../etc/passwd? জোড়া লাগালে ঠিকানাটি uploads থেকে দুই ধাপ ওপরে উঠে সার্ভারের সিস্টেম-ফাইলে পৌঁছায়। এই আক্রমণের নাম path traversal, আর এটি ওয়েব নিরাপত্তার সবচেয়ে পুরোনো আর এখনো সাধারণ ভুলগুলোর একটি। ২০১৮ সালে “Zip Slip” নামে একই ভুলের একটি রূপ বহু জনপ্রিয় লাইব্রেরিতে পাওয়া গিয়েছিল: zip ফাইলের ভেতরে ../-ওয়ালা নাম রাখলে খোলার সময় ফাইল ফোল্ডারের বাইরে লেখা হতো।

প্রতিরোধের নিয়ম তিন ধাপের: ভিত্তি-ফোল্ডারকে absolute আর normalize করুন; ব্যবহারকারীর নাম resolve করে আবার normalize করুন; তারপর যাচাই করুন ফলটি এখনো ভিত্তি-ফোল্ডারের ভেতরে কি না। ধাপে ধাপে দেখুন (সার্ভারের ঠিকানা /srv/shop ধরে নেওয়া উদাহরণ; সত্য-মিথ্যা ফলগুলো Java 21-এ যাচাই করা):

1Path base = Path.of("uploads").toAbsolutePath().normalize();2Path target = base.resolve(name).normalize();3if (!target.startsWith(base))4    throw new SecurityException("অনুমতি নেই: " + name);5return target;
যাচাইয়ের অবস্থা
name
target
startsWith(base)
  1. ভিত্তি ঠিক করা। uploads-কে absolute বানিয়ে normalize করা হলো। এটি না করলে পরের তুলনা relative আর absolute ঠিকানার মধ্যে হতো, আর ফল অর্থহীন হতো।
  2. সাধারণ নাম। report.pdf জুড়ে ঠিকানা uploads-এর ভেতরেই থাকল, startsWith সত্য, ফাইল পাঠানো হলো।
  3. আক্রমণ — শুধু resolve. ../../etc/passwd জোড়া হলো। resolve লেখা জোড়ে মাত্র; .. টুকরোগুলো রয়ে গেছে। এই অবস্থায় startsWith দেখলে সত্য আসত, কারণ লেখার শুরু এখনো /srv/shop/uploads — তাই normalize ছাড়া যাচাই অকেজো।
  4. normalize, তারপর যাচাই। দুটি .. দুই ধাপ ওপরে তুলল: /srv/etc/passwd. এটি আর ভিত্তির ভেতরে নয় — exception, ফাইল পাঠানো হলো না।
  5. পুরো ঠিকানা দেওয়া নাম। অংশ ২-এর ফাঁদ: absolute কিছু resolve করলে ভিত্তি বাদ পড়ে, ফল /etc/passwd. একই যাচাই এটিও ধরে ফেলে।
  6. লেখার তুলনা বনাম Path-এর তুলনা। /srv/shop/uploads-old/a.txt লেখা হিসেবে "/srv/shop/uploads" দিয়ে শুরু হয়, তাই String.startsWith সত্য বলত। Path.startsWith পুরো টুকরো ধরে মেলায় — uploads-old আর uploads আলাদা টুকরো — তাই মিথ্যা। সবসময় Path-এর method দিয়ে তুলনা করুন।

আরও দুটি স্তর: ভিত্তির ভেতরে symbolic link থাকলে normalize তা ধরে না — কঠোরতম যাচাই toRealPath(). আর সম্ভব হলে ব্যবহারকারীকে ঠিকানা দিতেই দেবেন না — রসিদের নম্বর নিন, ফাইলের নাম আপনার কোড বানাক।

Path দিয়ে জুড়ুন, normalize করুন,
তারপর startsWith(base) — ব্যবহারকারীর নাম কখনো সরাসরি নয়।
C/C++/Python জানলে

C-তে fopen/fclose আর FILE* — ব্যর্থ হলে NULL আর errno, exception নেই, আর বন্ধ করার দায়িত্ব পুরোপুরি আপনার (C-এর ফাইল I/O). C++17-এর std::filesystem::path প্রায় হুবহু Java-র Path, আর std::ifstream destructor-এ নিজে বন্ধ হয় — Java-র try-with-resources যে কাজটি করে (C++-এর ফাইল I/O ও filesystem). Python-এর pathlib.Path আর with open(...) এই পর্বের Path আর try-with-resources-এর সরাসরি জোড়া; Python-এও encoding="utf-8" স্পষ্ট লেখার পরামর্শ একই কারণে (Python-এর ফাইল I/O).

পরীক্ষা ও ইন্টারভিউতে
  • Path আর File-এর পার্থক্য? — File (Java 1.0) ঠিকানা আর কাজ একসাথে, ব্যর্থ হলে প্রায়ই false; Path (Java 7) শুধু ঠিকানা, কাজ করে Files, ব্যর্থ হলে নির্দিষ্ট IOException.
  • readAllLines আর Files.lines? — প্রথমটি পুরো ফাইল একবারে List-এ আনে (ছোট ফাইল); দ্বিতীয়টি অলস Stream, লাইন একে একে পড়ে (বড় ফাইল), আর try-with-resources-এ বন্ধ করতে হয়।
  • byte stream আর character stream? — InputStream/OutputStream কাঁচা byte (ছবি, zip); Reader/Writer charset দিয়ে অক্ষর (লেখা)।
  • delete আর deleteIfExists? — না থাকলে প্রথমটি NoSuchFileException ছোড়ে, দ্বিতীয়টি false ফেরত দেয়।
  • Java-র ডিফল্ট charset? — Java 18 (JEP 400) থেকে সব প্ল্যাটফর্মে UTF-8; Files-এর method আগে থেকেই UTF-8 ব্যবহার করত।

৯ · AI-এর যুগে — ফাইলের কোড রিভিউ করুন

“একটি CSV পড়ে রিপোর্ট বানাও” বললে AI-এর কোড ছোট নমুনা ফাইলে চলে যায়। সমস্যা লুকিয়ে থাকে নমুনার বাইরে: Windows-এ বাংলা, বড় ফাইল, টানা কয়েক দিন চলা সার্ভার, আর দুষ্ট ব্যবহারকারী। AI প্রশিক্ষিত হয়েছে বিশ বছরের কোডে, যার বড় অংশ java.io-যুগের — তাই new FileReader(...), হাতে জোড়া path আর বন্ধ না করা stream খুবই সাধারণ।

AI যা লেখেকেন সমস্যাআপনি যা চাইবেন
new FileReader("data.csv"), charset নেইJava 17 বা তার আগে Windows-এ বাংলা নষ্ট; বাইরের ফাইলের charset অজানাFiles.newBufferedReader(p, StandardCharsets.UTF_8)
Files.lines(p).filter(...).count(), try ছাড়াফাইল খোলা থাকে; সার্ভারে একসময় Too many open filestry-with-resources; Files.walk/list-এও
কয়েক GB লগে readAllLinesপুরো ফাইল মেমরিতে, OutOfMemoryErrorFiles.lines বা BufferedReader, লাইন ধরে
"C:\\data\\" + nameঅন্য অপারেটিং সিস্টেমে ভাঙে; .. ঢোকার পথPath.of("data").resolve(name)
ব্যবহারকারীর দেওয়া নাম সরাসরি Path.of(input)path traversalresolve → normalize → startsWith(base)
catch (IOException e) {}ফাইল না পেলেও চুপচাপ ফাঁকা ফলবোঝার মতো বার্তা, বা exception ওপরে পাঠানো

AI-কে ফাইলের কোড লেখাতে বা রিভিউ করাতে এই কাঠামো ব্যবহার করুন:

আমি Java 21 শিখছি; Stream, record আর try-with-resources জানি। কাজ: [যেমন — একটি বিক্রির CSV (জেলা, পণ্য, টাকা) পড়ে জেলাভিত্তিক মোট বিক্রি বের করা আর report.csv-তে লেখা] শর্ত: ১. শুধু java.nio.file — Path আর Files; java.io.File, FileReader, FileWriter নয়। ২. প্রতিটি পড়া-লেখায় StandardCharsets.UTF_8 স্পষ্ট লিখবে। ৩. ফাইল কয়েক GB হতে পারে ধরে নাও: readAllLines নয়, Files.lines বা BufferedReader. ৪. Files.lines, Files.list, Files.walk, BufferedWriter — সব try-with-resources-এ। ৫. path জোড়া শুধু Path.of আর resolve দিয়ে; ফাইলের নাম ব্যবহারকারী দিলে normalize আর startsWith দিয়ে যাচাই। ৬. IOException গিলে ফেলবে না; ফাইল না পেলে বোঝার মতো বার্তা দেবে। ৭. CSV-তে কমা-ভরা উদ্ধৃতি থাকতে পারে কি না জানাও, আর থাকলে কোন লাইব্রেরি ভালো তা বলো। ৮. শেষে একটি তালিকা দাও: কোন লাইনে কোন ফাইল খোলা হচ্ছে আর কোথায় বন্ধ হচ্ছে।

AI-এর কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১Java-তে ফাইলের API তিনবার নতুন করে বানানো হয়েছে — java.io (১৯৯৬), NIO (২০০২), NIO.2 (২০১১)। প্রতিবার কোন সমস্যা সমাধান করতে, আর পুরোনোটি সরানো হয়নি কেন?

java.io — প্রথম প্রজন্ম। ১৯৯৬ সালের JDK 1.0-এ ফাইলের কাজ ছিল stream-কেন্দ্রিক: InputStream আর OutputStream, যেগুলো একটির পর একটি byte পড়ে-লেখে, আর decorator-এর মতো একটির ওপর আরেকটি মোড়ানো যায়। JDK 1.1-এ (১৯৯৭) এল Reader আর Writer, কারণ বোঝা গেল byte আর অক্ষর এক নয় — Java-র char শুরু থেকেই Unicode. এই নকশা সরল ছিল, কিন্তু দুটি দুর্বলতা নিয়ে। প্রথমত, এটি blocking: একটি thread একটি সংযোগ থেকে পড়তে বসলে ডেটা না আসা পর্যন্ত আটকে থাকে, তাই হাজার সংযোগের সার্ভারে হাজার thread লাগত। দ্বিতীয়ত, File class ছিল দুর্বল: ব্যর্থতার কারণ জানাত না, symbolic link বুঝত না, ফাইলের অনুমতি বা মালিক পড়তে পারত না।

NIO — সার্ভারের জন্য। ২০০২ সালের Java 1.4-এ JSR 51-এর মাধ্যমে এল “New I/O”: ByteBuffer-এর মতো buffer, FileChannel আর SocketChannel-এর মতো channel, একটি thread দিয়ে অনেক সংযোগ পাহারা দেওয়ার Selector, আর java.nio.charset. লক্ষ্য ছিল C-র কাছাকাছি গতি — অপারেটিং সিস্টেমের নিচু-স্তরের সুবিধা (memory map, non-blocking socket) Java-তে আনা। পরের বহু জনপ্রিয় নেটওয়ার্ক লাইব্রেরি, যেমন Netty, এর ওপর দাঁড়িয়ে। কিন্তু দৈনন্দিন ফাইলের কাজ — কপি, সরানো, ফোল্ডারের তালিকা — তখনো সেই পুরোনো File দিয়েই করতে হতো।

NIO.2 — ফাইল-সিস্টেমের জন্য। ২০১১ সালের Java 7-এ JSR 203 সেই ফাঁক পূরণ করল: Path, Files, FileSystem, নির্দিষ্ট exception-এর পরিবার, symbolic link, ফাইলের বৈশিষ্ট্য, WatchService, আর asynchronous channel. একই বছরে এল try-with-resources, যা এই API-কে নিরাপদে ব্যবহারযোগ্য করে তুলল। পরে Java 8-এ Files.lines আর Files.walk (Stream-এর সাথে), আর Java 11-এ readString, writeString ও Path.of একে আরও ছোট করেছে।

পুরোনোটি কেন রয়ে গেল। Java-র সবচেয়ে বড় প্রতিশ্রুতি backward compatibility: ১৯৯৮ সালে লেখা কোড আজও compile হওয়া উচিত। লক্ষ লক্ষ লাইব্রেরি File আর InputStream নেয় বা ফেরত দেয়, তাই এগুলো সরালে পুরো ইকোসিস্টেম ভেঙে যেত। তার বদলে সেতু বানানো হয়েছে — toPath(), toFile(), Files.newInputStream. ফলে আজও তিন প্রজন্ম পাশাপাশি থাকে, আর নতুনদের শিখতে হয় কোনটি কখন।

মূল উপলব্ধি: একটি জনপ্রিয় platform-এ API কখনো মুছে ফেলা যায় না, শুধু পাশে নতুন একটি বসানো যায়। তাই প্রথম নকশার ভুল দশকের পর দশক টিকে থাকে — আর পুরোনো আর নতুনের মধ্যে সেতু বানানো API নকশার একটি আলাদা দক্ষতা।

প্র ০২Java 18-এ JEP 400 সব প্ল্যাটফর্মে ডিফল্ট charset UTF-8 করেছে। এর আগে সমস্যাটা কী ছিল, mojibake কেন হয়, আর এই পরিবর্তন কেন এত দেরিতে এল?

encoding-এর ইতিহাস। কম্পিউটারের শুরুর দিকে ASCII ছিল ৭ bit-এর — ১২৮টি অক্ষর, শুধু ইংরেজি। বাকি ১২৮টি জায়গা (৮ bit-এর ওপরের অর্ধেক) প্রতিটি দেশ ও কোম্পানি নিজের মতো ভরেছিল: পশ্চিম ইউরোপের জন্য ISO-8859-1, Microsoft-এর Windows-1252, রাশিয়ার KOI8-R, জাপানের Shift_JIS. বাংলার জন্য কোনো মান ছিল না, তাই বিজয়ের মতো সফটওয়্যার ASCII-র জায়গায় বিশেষ ফন্ট দিয়ে বাংলা আকার দেখাত — ফাইলে আসলে ইংরেজি অক্ষরের কোড, ফন্ট বদলালেই হিজিবিজি। Unicode (১৯৯১) প্রতিটি অক্ষরকে একটি স্বতন্ত্র নম্বর দিল, আর UTF-8 (১৯৯২, Ken Thompson ও Rob Pike) সেই নম্বরকে byte-এ লেখার এমন একটি নিয়ম দিল, যা ASCII-র সাথে পুরোপুরি মেলে। আজ ওয়েবের বিপুল সংখ্যাগরিষ্ঠ পাতা UTF-8.

mojibake কেন। byte নিজে কোনো অক্ষর নয়; অর্থ আসে নিয়ম থেকে। UTF-8-এ “আ” হলো e0 a6 86. Windows-1252 এই তিনটিকে তিনটি আলাদা অক্ষর ভাবে — à, ¦, †. ফলে byte অক্ষত থাকে, কিন্তু পর্দায় হিজিবিজি। আরও খারাপ হয় যখন সেই হিজিবিজি আবার UTF-8-এ সেভ করা হয় — তখন মূল byte-ই বদলে যায়, আর ফেরানো কঠিন হয়ে পড়ে।

Java-র পুরোনো সমস্যা। Java 17 পর্যন্ত FileReader, FileWriter, String.getBytes()-এর মতো charset-হীন API অপারেটিং সিস্টেমের locale থেকে ডিফল্ট নিত। ডেভেলপার Mac বা Linux-এ (UTF-8) পরীক্ষা করে সব ঠিক দেখতেন; একই প্রোগ্রাম ক্লায়েন্টের ইংরেজি Windows-এ (Windows-1252) বাংলা নষ্ট করত।

JEP 400 আর দেরির কারণ। ২০২২ সালের Java 18-এ JEP 400 “UTF-8 by Default” ডিফল্টটিকে সব প্ল্যাটফর্মে এক করে দিল। দেরির কারণ সেই backward compatibility: যে প্রোগ্রাম বছরের পর বছর Windows-1252-এ ফাইল লিখে আসছে, ডিফল্ট বদলালে সে নিজের পুরোনো ফাইলই ভুল পড়বে। তাই JDK দল আগে থেকে প্রস্তুতি নিয়েছিল — Java 17-এ native.encoding নামে একটি property যোগ করা হয়, যাতে প্রোগ্রাম অপারেটিং সিস্টেমের আসল encoding জানতে পারে, আর পুরোনো আচরণ ফেরাতে -Dfile.encoding=COMPAT বিকল্প রাখা হয়। মজার ব্যাপার, Files-এ এই সিদ্ধান্ত আগেই নেওয়া হয়েছিল: Java 7-এ সেখানে charset দেওয়া বাধ্যতামূলক ছিল, আর Java 8-এ (২০১৪) charset ছাড়া রূপগুলো যোগ হলে সেগুলোর ডিফল্ট রাখা হয় UTF-8, অপারেটিং সিস্টেম যা-ই হোক।

মূল উপলব্ধি: লেখা মানে অক্ষর আর নিয়মের জোড়া; নিয়ম হারালে লেখাও হারায়। “ডিফল্ট” যদি মেশিনভেদে বদলায়, তাহলে সেটা আসলে লুকানো একটি ইনপুট — তাই চলার পরিবেশ নির্বিশেষে একই ফল চাইলে charset স্পষ্ট লিখুন।

প্র ০৩Unix-এর দর্শন বলে “সবকিছুই ফাইল”। এর মানে কী, এটি কেন এত শক্তিশালী, আর Java-র I/O নকশায় এর প্রভাব কোথায়?

ধারণাটি। ১৯৬৯-৭০ সালে Bell Labs-এ Ken Thompson আর Dennis Ritchie যখন Unix বানাচ্ছিলেন, তাঁরা একটি সাহসী সরলীকরণ করেন: প্রায় সব ধরনের ইনপুট-আউটপুট একই কয়েকটি system call দিয়ে চলবে — open, read, write, close. ডিস্কের ফাইল তো বটেই, keyboard আর terminal, printer, এমনকি হার্ডডিস্ক নিজেও /dev ফোল্ডারে একটি “ফাইল” হিসেবে দেখা দেয়। পরে pipe (এক প্রোগ্রামের আউটপুট আরেকটির ইনপুট) আর network socket-ও একই file descriptor পেল। Linux-এর /proc ফোল্ডারে চলমান প্রোগ্রাম আর kernel-এর তথ্য পর্যন্ত ফাইল হিসেবে পড়া যায় — Linux-এ Files.readString(Path.of("/proc/cpuinfo")) লিখলে CPU-র বিবরণ পাওয়া যায়।

শক্তি কোথায়। একটি সাধারণ ইন্টারফেস মানে প্রতিটি টুল সবকিছুর সাথে কাজ করে। cat, grep, sort জানে না ডেটা ফাইল থেকে আসছে, নেটওয়ার্ক থেকে, নাকি অন্য প্রোগ্রাম থেকে — তাদের কাছে সবই byte-এর স্রোত। পর্ব ২২-এ যে Unix philosophy-র কথা পড়েছেন — ছোট টুল, একটি কাজ, text দিয়ে যোগাযোগ — তা এই ভিত্তির ওপর দাঁড়িয়ে। Bell Labs-এর পরের প্রজেক্ট Plan 9 ধারণাটিকে আরও দূর নিয়ে গিয়েছিল: নেটওয়ার্কের অন্য কম্পিউটারের সম্পদ, এমনকি জানালা-ব্যবস্থাও সেখানে ফাইল।

Java-য় প্রভাব। Java-র InputStream হুবহু এই ভাবনার object-oriented রূপ: FileInputStream, socket.getInputStream(), System.in, ByteArrayInputStream — সবার একই read(). যে method একটি InputStream নেয়, সে ফাইল, নেটওয়ার্ক বা মেমরি — যেকোনো উৎস থেকে পড়তে পারে। এটাই polymorphism (পর্ব ২০) আর program-to-interface-এর একটি বড় বাস্তব উদাহরণ। NIO.2-এর FileSystem abstraction আরও এক ধাপ এগিয়েছে: JDK-র zip file system provider দিয়ে একটি zip ফাইলের ভেতরের জিনিসকে সাধারণ Path হিসেবে Files.copy বা Files.walk করা যায়, আর পরীক্ষার জন্য মেমরির ভেতরে নকল file system বানানোর লাইব্রেরিও আছে।

সীমা। সবকিছুকে byte-স্রোত বানালে গঠন হারায়। একটি ডেটাবেসের টেবিল বা একটি GPU-কে ফাইল হিসেবে ভাবা কষ্টকর, আর Windows ঐতিহাসিকভাবে আলাদা পথে গেছে — সেখানে অনেক কিছু ফাইল নয়, আলাদা API. তবু বিমূর্ততার এই শক্তি — “কোথা থেকে আসছে না জেনেই কাজ করা” — আজও প্রায় সব I/O নকশার কেন্দ্রে।

মূল উপলব্ধি: একটি সাধারণ, ছোট ইন্টারফেস হাজারটা টুলকে একে অপরের সাথে জুড়তে দেয়। Java-তে আপনি যখন File নয়, InputStream বা Reader নেওয়া method লেখেন, তখন আপনিও সেই পঞ্চাশ বছরের পুরোনো দর্শনই প্রয়োগ করছেন।

প্র ০৪BufferedWriter ছাড়া এক লক্ষ লাইন লিখলে প্রোগ্রাম অনেক ধীর হয়ে যায়। system call আসলে কী, buffer কীভাবে সাহায্য করে, আর buffer-এর আকার কত হওয়া উচিত?

user mode আর kernel mode. আধুনিক CPU দুই অবস্থায় চলে। আপনার প্রোগ্রাম চলে সীমিত “user mode”-এ — সে নিজে ডিস্ক বা নেটওয়ার্কের হার্ডওয়্যার ছুঁতে পারে না। এসব কাজ করে অপারেটিং সিস্টেমের kernel, পূর্ণ ক্ষমতার “kernel mode”-এ। প্রোগ্রাম যখন ফাইলে লিখতে চায়, তখন একটি system call করে — Linux-এ write. এতে CPU অবস্থা বদলায়, kernel অনুমতি যাচাই করে, ডেটা কপি করে, তারপর আবার user mode-এ ফেরে। একটি সাধারণ method ডাক যেখানে কয়েক ন্যানোসেকেন্ড, সেখানে একটি system call অনেক গুণ বেশি সময় নেয় — আর নিরাপত্তার জন্য সাম্প্রতিক CPU-গুলোতে (Spectre/Meltdown-এর পরের সুরক্ষা) এই খরচ আরও বেড়েছে।

buffer-এর হিসাব। FileWriter-এ প্রতিটি write("একটি লাইন\n") যদি সরাসরি একটি system call হয়, এক লক্ষ লাইনে এক লক্ষ বার kernel-এ যাওয়া-আসা। BufferedWriter তার ডিফল্ট ৮১৯২ অক্ষরের buffer ভরা পর্যন্ত মেমরিতে জমায়, তারপর একবারে পাঠায়। গড়ে ৫০ অক্ষরের লাইন হলে প্রায় ১৬০ লাইনে একবার — এক লক্ষ লাইনে মাত্র কয়েকশো system call. পড়ার দিকেও একই: BufferedReader একবারে বড় টুকরো পড়ে রাখে, তারপর readLine() মেমরি থেকে দেয়। কাজ একই, কিন্তু kernel-এর দরজায় কড়া নাড়া শত গুণ কম।

আকার কত হবে। বড় buffer মানে কম system call, কিন্তু বেশি মেমরি আর লেখা ডিস্কে পৌঁছাতে বেশি দেরি। ৮ KB-র ডিফল্ট একটি পুরোনো, পরীক্ষিত আপস — ডিস্কের block আর মেমরির page-এর আকার (সাধারণত ৪ KB) এর কাছাকাছি। খুব বড় ফাইল কপিতে ৬৪ KB বা তার বেশি কিছুটা দ্রুত হতে পারে, কিন্তু একটা সীমার পর লাভ প্রায় শূন্য, কারণ তখন আসল বাধা ডিস্কের গতি। তাই অনুমান না করে মেপে দেখুন — Java-তে এ কাজের জন্য JMH-এর মতো benchmark টুল আছে।

মূল উপলব্ধি: I/O-তে খরচ মাপা হয় byte-এ নয়, সীমানা পারাপারের সংখ্যায়। ছোট ছোট অনেক অনুরোধকে কয়েকটি বড় অনুরোধে জোড়া — buffering — কম্পিউটার বিজ্ঞানের সবচেয়ে কার্যকর কৌশলগুলোর একটি, ফাইল থেকে নেটওয়ার্ক আর ডেটাবেস পর্যন্ত সবখানে।

প্র ০৫Kafka-র মতো সিস্টেম সেকেন্ডে বিপুল ডেটা ডিস্ক থেকে নেটওয়ার্কে পাঠায়। memory-mapped file আর zero-copy কী, Java-তে FileChannel দিয়ে এগুলো কীভাবে পাওয়া যায়, আর সাধারণ অ্যাপে এগুলো কেন দরকার নেই?

সাধারণ পথের কপি। একটি ফাইল পড়ে নেটওয়ার্কে পাঠানোর সাধারণ কোড — read করে একটি byte[]-এ, তারপর socket-এ write — নিরীহ দেখালেও ভেতরে ডেটা কয়েকবার কপি হয়: ডিস্ক থেকে kernel-এর page cache-এ, সেখান থেকে আপনার প্রোগ্রামের array-তে, সেখান থেকে আবার kernel-এর socket buffer-এ, তারপর নেটওয়ার্ক কার্ডে। মাঝখানের দুটি কপি আর তার সাথের system call আসলে অপ্রয়োজনীয় — আপনার প্রোগ্রাম তো ডেটাটা দেখছেই না, শুধু এক জায়গা থেকে আরেক জায়গায় পাঠাচ্ছে।

zero-copy. Java 1.4-এর NIO-তে আসা FileChannel-এর transferTo(position, count, targetChannel) kernel-কে সরাসরি বলে: “এই ফাইলের এই অংশ ওই socket-এ পাঠাও।” Linux-এ এটি sendfile system call ব্যবহার করতে পারে, যেখানে ডেটা user-space-এ আসেই না। Apache Kafka-র নকশা-দলিলে স্পষ্ট লেখা আছে, consumer-দের কাছে log-এর ডেটা পাঠাতে তারা এই zero-copy পথ ব্যবহার করে — এটি Kafka-র উচ্চ গতির একটি বড় কারণ। Files.copy-এর ভেতরেও JDK প্ল্যাটফর্মভেদে এমন দ্রুত পথ নিতে পারে।

memory-mapped file. FileChannel.map(...) একটি MappedByteBuffer দেয়: অপারেটিং সিস্টেম ফাইলের একটি অংশকে প্রোগ্রামের মেমরি-ঠিকানায় বসিয়ে দেয়। এরপর ফাইল পড়া মানে মেমরি পড়া — buffer.get(i); যে অংশ এখনো মেমরিতে আসেনি, তা ছুঁলেই kernel নিজে ডিস্ক থেকে আনে (page fault). বিশাল ফাইলে এলোমেলো জায়গা থেকে পড়ার কাজে — ডেটাবেসের index, search engine — এটি খুব কার্যকর। সীমাও আছে: একটি MappedByteBuffer সর্বোচ্চ ২ GB-র কাছাকাছি (index int), আর map ছাড়ার কোনো সরাসরি পদ্ধতি এতে ছিল না। Java 22-এ স্থায়ী হওয়া Foreign Function & Memory API (JEP 454) MemorySegment দিয়ে বড় map আর নিয়ন্ত্রিতভাবে মুক্ত করার পথ দিয়েছে।

কেন সাধারণ অ্যাপে নয়। এই কৌশলগুলো জটিল, প্ল্যাটফর্ম-নির্ভর, আর ভুল হলে সূক্ষ্ম bug দেয় — map করা ফাইল অন্য প্রোগ্রাম ছোট করে ফেললে crash পর্যন্ত হতে পারে। একটি সাধারণ ব্যবসায়িক অ্যাপে ফাইল I/O খুব কমই আসল বাধা; ডেটাবেস বা নেটওয়ার্কের দেরি অনেক বড়। তাই নিয়ম: আগে Files আর buffer দিয়ে সরল, পরিষ্কার কোড; মেপে যদি দেখা যায় ফাইল I/O-ই বাধা, তবেই FileChannel-এর গভীরে।

মূল উপলব্ধি: দ্রুততম কপি হলো যে কপি হয়ই না। উচ্চ-গতির সিস্টেমের রহস্য প্রায়ই নতুন অ্যালগরিদম নয়, বরং ডেটাকে অকারণে এক জায়গা থেকে আরেক জায়গায় না নেওয়া — আর সেটা বুঝতে হলে জানতে হয় আপনার কোডের নিচে অপারেটিং সিস্টেম কী করছে।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।

  1. আউটপুট অনুমান করুন: অংশ ৩-এর হিসাব খাতা প্রোগ্রামে writeString-এর লাইন থেকে , StandardOpenOption.APPEND অংশটি মুছে দিন। এখন কী ছাপা হবে, আর কেন?

    দুই লাইন: বিদ্যুৎ বিল → 1200 টাকা আর মোট 1টি খরচ, 1200 টাকা. option না দিলে ডিফল্ট হলো CREATE, TRUNCATE_EXISTING, WRITE — ফাইল খালি করে নতুন লেখা। তাই চাল আর রিকশা ভাড়ার লাইন মুছে গেল। কোনো exception নেই, কোনো সতর্কবার্তা নেই — ডেটা হারানোর এই ভুল শুধু ফল দেখেই ধরা যায়। এজন্যই “লেখা” আর “যোগ করা” আলাদা করে ভাবতে হয়।

  2. ভাঙা CSV: অংশ ৬-এর বিক্রির প্রোগ্রামে একটি লাইন যোগ করুন: w.write("ঢাকা,\"চা, বিস্কুট\",500\n"); কী হবে? তারপর লাইনটি বদলে w.write("সিলেট,চা,৩০০\n"); করুন (টাকা বাংলা অঙ্কে)। এবার কী হবে?

    প্রথমবার প্রোগ্রাম ভেঙে পড়বে: split(",") উদ্ধৃতি বোঝে না, চারটি টুকরো দেয় — ঢাকা, "চা, বিস্কুট", 500 — আর parseInt(p[2]) দেয় NumberFormatException: For input string: " বিস্কুট"". stack trace দেখাবে lambda-র ভেতরের লাইন। দ্বিতীয়বার প্রোগ্রাম ঠিকই চলবে, আর একটি নতুন লাইন আসবে সিলেট: 300 টাকা (সবার শেষে, কারণ Unicode-এ স আসে র-এর পরে) — কারণ Integer.parseInt বাংলাসহ যেকোনো Unicode অঙ্ক চেনে। শিক্ষা: split উদ্ধৃতির নিয়ম জানে না, আর ইনপুট যাচাইয়ে ধরে নেবেন না যে শুধু 0-9 আসবে।

  3. রিভিউ করে আবার লিখুন: একজন AI এই method দিয়েছে, যা ব্যবহারকারীর দেওয়া নামের রিপোর্ট ফাইলে “ERROR” লেখা লাইন গোনে। অন্তত পাঁচটি সমস্যা খুঁজুন, তারপর ঠিক করে লিখুন:
    static long countErrors(String userFile) {
        try {
            Path p = Path.of("C:\\reports\\" + userFile);
            List<String> all = Files.readAllLines(p);
            return Files.lines(p).filter(l -> l.contains("ERROR")).count();
        } catch (IOException e) {
            return 0;
        }
    }

    সমস্যা: (১) String জুড়ে Windows-এর path — Linux সার্ভারে অকেজো; (২) ব্যবহারকারীর নাম যাচাই ছাড়া — ..\..\ বা পূর্ণ path দিয়ে যেকোনো ফাইল পড়া যায়; (৩) readAllLines পুরো ফাইল মেমরিতে আনে, অথচ ফলটি ব্যবহারই হয়নি — বড় ফাইলে OutOfMemoryError; (৪) Files.lines বন্ধ হয় না — file handle leak; (৫) catch করে 0 ফেরত — “ফাইল নেই” আর “কোনো ভুল নেই” একই দেখায়। একটি ভালো রূপ:

    static final Path BASE = Path.of("reports").toAbsolutePath().normalize();
    
    static long countErrors(String userFile) throws IOException {
        Path p = BASE.resolve(userFile).normalize();
        if (!p.startsWith(BASE))
            throw new SecurityException("অনুমতি নেই: " + userFile);
        try (Stream<String> lines = Files.lines(p, StandardCharsets.UTF_8)) {
            return lines.filter(l -> l.contains("ERROR")).count();
        }
    }
  4. নকশার সিদ্ধান্ত: অংশ ৭-এর “Downloads গোছানো” টুলের জন্য তিনটি প্রশ্নের উত্তর লিখুন — কোড নয়, সিদ্ধান্ত আর কারণ: (ক) Files.list নাকি Files.walk? (খ) images-এ একই নামের ফাইল আগে থেকে থাকলে কী করবেন? (গ) টুলটি মাঝপথে বন্ধ হয়ে গেলে আবার চালালে কোনো ক্ষতি হবে কি?

    (ক) Files.list — শুধু ওপরের স্তর। walk নিলে নতুন বানানো images-এর ফাইল আবার ধরা পড়বে, আর ব্যবহারকারীর নিজের ভেতরের ফোল্ডার এলোমেলো হবে। ভালো হয় try-with-resources-এ toList() করে তালিকা বানিয়ে stream বন্ধ করা, তারপর সরানো। (খ) REPLACE_EXISTING দিলে ব্যবহারকারীর একটি ফাইল চুপচাপ হারাবে — ফাইল-টুলে এটা অগ্রহণযোগ্য। ভালো: photo (1).jpg, photo (2).jpg — যে নাম খালি পাওয়া যায়; আর move-এ option ছাড়া চেষ্টা করে FileAlreadyExistsException ধরলে পরের নাম — এতে TOCTOU-ও এড়ানো যায়। (গ) প্রতিটি move একটি ফাইলের জন্য সম্পূর্ণ (একই ডিস্কে নাম বদলের মতো), তাই মাঝপথে থামলে কিছু ফাইল সরানো, কিছু বাকি থাকবে। আবার চালালে বাকিগুলো সরবে, আর সরানোগুলো list-এ আসবেই না। এমন “বারবার চালালেও একই ফল” গুণকে বলে idempotent — ফাইলের টুলে এটা খুব কাম্য।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমJava-তে একটি বিক্রির CSV থেকে জেলাভিত্তিক মোট বের করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📍 Path.of("sales.csv") দিয়ে ঠিকানা ঠিক করা
  2. 📂 try-with-resources-এ Files.lines(csv) খোলা
  3. ⏭️ skip(1) দিয়ে শিরোনামের লাইন বাদ
  4. ✂️ split(",") দিয়ে প্রতিটি লাইন ঘরে ভাগ
  5. 🧮 groupingBy আর summingInt দিয়ে জেলাভিত্তিক যোগফল
  6. 🖨️ try শেষে ফাইল বন্ধ, তারপর ফল ছাপা

Java-তে Files.readString(Path.of("nai.txt")) চালানো হলো, কিন্তু nai.txt নামে কোনো ফাইল নেই। কী ঘটে?

  • readString null ফেরত দেয়
  • java.nio.file.NoSuchFileException: nai.txt
  • একটি ফাঁকা ফাইল তৈরি হয়ে "" ফেরত আসে
  • java.io.FileNotFoundException

Java-তে Path.of("data/../reports/./jan.csv").normalize() কী দেয়?

  • data/../reports/./jan.csv
  • data/../reports/jan.csv
  • reports/jan.csv
  • working directory সহ পূর্ণ absolute ঠিকানা

Java-তে Files.lines(path)-কে try-with-resources-এ রাখার মূল কারণ কী?

  • stream-এর পেছনে খোলা ফাইল থাকে, যা নিজে নিজে বন্ধ হয় না
  • try ছাড়া Files.lines compile হয় না
  • try-with-resources পড়াকে দ্রুত করে
  • try ছাড়া charset UTF-8 হয় না

Java 18 আর তার পরের সংস্করণে, অপারেটিং সিস্টেম যা-ই হোক, Java-র ডিফল্ট charset কোনটি?

  • Windows-1252
  • UTF-16
  • অপারেটিং সিস্টেমের locale যা বলে
  • UTF-8

Java-তে b.txt আগে থেকে আছে। Files.copy(Path.of("a.txt"), Path.of("b.txt")) — কোনো option ছাড়া — চালালে কী হয়?

  • b.txt নিঃশব্দে a.txt-এর লেখা দিয়ে বদলে যায়
  • FileAlreadyExistsException: b.txt
  • a.txt-এর লেখা b.txt-এর শেষে যোগ হয়
  • কিছুই হয় না, false ফেরত আসে

Java-তে ব্যবহারকারীর দেওয়া ফাইলের নাম name, আর base = Path.of("uploads").toAbsolutePath().normalize(). path traversal আটকানোর জন্য কোন যাচাই সঠিক?

  • name.contains("../") হলে আটকানো
  • base.resolve(name).startsWith(base)
  • base.resolve(name).normalize().startsWith(base)
  • (base + "/" + name).startsWith(base.toString())

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Path ঠিকানা, Files কাজ: Path.of আর resolve দিয়ে জুড়ুন, String দিয়ে নয়; normalize শুধু লেখা পরিষ্কার করে। পুরোনো File পেলে toPath().
  2. ছোট ফাইলে readString/writeString/readAllLines: লেখা ডিফল্টে আগেরটা মুছে দেয়; যোগ করতে CREATE, APPEND. ব্যর্থতা নির্দিষ্ট exception — NoSuchFileException, FileAlreadyExistsException, DirectoryNotEmptyException.
  3. ফাইলে byte, অক্ষর নয়: বাংলা অক্ষর UTF-8-এ তিন byte; ভুল charset মানে mojibake বা MalformedInputException. Files ডিফল্টে UTF-8, Java 18 থেকে পুরো Java-ও; বাইরের ফাইলে charset স্পষ্ট লিখুন।
  4. বড় ফাইলে লাইন ধরে, আর সবসময় বন্ধ: Files.lines, list, walk, reader, writer — try-with-resources-এ। BufferedWriter-এর লেখা close/flush-এর আগে ডিস্কে নাও থাকতে পারে।
  5. ব্যবহারকারীর path বিশ্বাস করবেন না: base.resolve(name).normalize().startsWith(base); নিরাপদ সেভের জন্য temp ফাইলে লিখে ATOMIC_MOVE; বাইরের CSV-তে split নয়, পরীক্ষিত লাইব্রেরি।

পরের পর্বে: হিসাব খাতায় এখন তারিখ লেখা আছে শুধু একটি String হিসেবে — "2026-10-18". দুই তারিখের মধ্যে কত দিন, এ মাসের খরচ কত, ঢাকার সময় আর লন্ডনের সময়ের পার্থক্য — এসবের জন্য পর্ব ৩৮-এ java.time: LocalDate, Duration, time zone আর তারিখ ফরম্যাট করা।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান