Viết Reminder Parser dùng Rust

Công Nghệ
Viết Reminder Parser dùng Rust

Bài viết được sự cho phép của tác giả Huy Trần 

Khi dùng Google Calendar hoặc Reminder.app của macOS, mình rất thích chức năng tạo nhanh một event bằng cách nhập vào nội dung một cách tự nhiên như khi đang nói, ví dụ:

get hair cut at 10am every Sunday

hoặc là

doctor appointment at 1pm on Monday

Khi nhận được input như này, một event mới sẽ được tạo ra với ngày và giờ tương ứng, còn nội dung của event sẽ là phần text mở đầu, ví dụ “get hair cut”, hoặc “doctor appointment”.

Thường thì cái gì mình thích, mình sẽ tìm cách clone lại, chức năng này cũng ko ngoại lệ.

Vì bài viết này là phần tiếp theo của bài viết trước, chúng ta sẽ tiếp tục dùng Rust và Nom. Recommend các bạn đọc kĩ phần trước, và chuẩn bị những kiến thức cơ bản của Rust, nhất là về ResultOptioncargo test, trước khi đọc tiếp bài này.

Các bạn cũng có thể tham khảo code implementation hoàn chỉnh tại Github trước khi bắt đầu: https://github.com/huytd/reminder-parser

Xem thêm việc làm SSIS hấp dẫn trên Station D

Phân tích cú pháp

Đây không phải là cú pháp mà Google Calendar hay Reminder.app sử dụng, nhưng đây sẽ là cú pháp chúng ta sẽ dùng trong bài viết này, đơn giản là vì nó… đơn giản 

Cấu trúc của một event input là một tổ hợp của nhiều token như sau:

Trong đó:

  • <event-text> là nội dung của event cần tạo.
  • <time> là thời gian diễn ra event, có thể nhập thời gian ở 2 dạng: at 10pm hoặc 11:32. Từ khóa “at” có thể được bỏ đi, phần meridiem indicator (“am” hoặc “pm”) cũng là optional.Một token <time> sẽ có dạng:
    time = ?"at" + hour + ?(":" + minutes) + ?("am"|"pm")
    

    Nếu chỉ có giá trị giờ (hour) mà không có phút (minutes) thì mặc định sẽ là 00 phút.

  • <repeat> và <date>: Hai token này đi chung với nhau vì đôi lúc, những event sẽ lặp lại định kỳ (ví dụ nhắc trả bill hàng tháng, ví dụ every 15th), cũng có thể chỉ diễn ra một lần (on Monday), giá trị on hoặc every sẽ được dùng để xác định token <repeat>.
    date = ?("on"|"every") + date
    

Để cho đơn giản thì chúng ta sẽ tạm bỏ qua việc xác thực nội dung nhập vào, nên những case như thế này vẫn sẽ được chấp nhận:

32:42 pm
hoc
24:59

Trên thực tế, validation cũng không phải là nhiệm vụ của parser.

Thuật toán parse event

Vậy ta sẽ parse nội dung như thế nào sau khi đã xác định được 3 thành phần trên?

Thuật toán parse của chúng ta sẽ duyệt từng kí tự từ đầu đến cuối string, và tìm cách parse ra từng token.

Chạy từ đầu input cho đến trước khi ta gặp phần tử đầu tiên của một <time> token, lưu tất cả kí tự đã gặp lại:

Tiếp theo, khi bắt gặp phần tử đầu tiên của <time> token (là chữ “at”), cho đến cuối (là khi đọc được phần meridiem indicator “am” hoặc “pm”) ta sẽ bắt đầu quá tình parse <time> token.

Sau khi xác định xong <time> token, đi tiếp và nếu gặp “on” hoặc “every”, ta sẽ bắt đầu parse <date> token.

Sau khi đã xác định được <time> token và <date> token, thì phần nội dung chúng ta đã lưu lại từ đầu chính là <event-text>. Đến lúc này ta hoàn tất quá trình parse.

Implementation

Xác định xong cú pháp và thuật toán là coi như giải quyết xong 75% bài toán rồi, 95% còn lại nằm ở việc đánh nhau với Rust bây giờ ta implement thôi.

Việc đầu tiên là define ra cấu trúc dữ liệu cho kết quả sau khi parse, ta sẽ gọi nó là ReminderEvent:

struct ReminderEvent<'a> {
    text: String,
    date: ReminderDate<'a>,
    time: ReminderTime<'a>
}

Trường text là nội dung của event, kiểu dữ liệu cho date và time lần lượt sẽ là:

struct ReminderDate<'a> {
    content: &'a str,
    repeated: bool
}

struct ReminderTime<'a> {
    hour: &'a str,
    minute: &'a str,
    meridiem: bool
}

Một ReminderDate sẽ chứa content là một chuỗi thô chưa qua xử lý, ví dụ như “Tuesday”“14/12”. Bao giờ cần xài thì parse tiếp sau, trong bài viết này mình sẽ lưu raw. Nếu đây là một event diễn ra định kì (có chữ “every” khi parse date), thì trường repeated sẽ là true.

Một ReminderTime chứa các giá trị giờ / phút dưới dạng chuỗi thô, giá trị meridiem indicator sẽ được lưu trong trường meridiem, vì nó có thể có, có thể không tồn tại trong input, mặc định sẽ là true cho “am”, và false cho “pm”.

Vì các giá trị raw được lưu dưới dạng string slice &str, tham chiếu trực tiếp từ input, bản thân Rust compiler không tự suy ra được lifetime cho các giá trị này, nên ta phải tự annotate bằng <'a>.

Tiếp theo, kế hoạch thực hiện sẽ là:

  1. Viết hàm parse ReminderTime
  2. Viết hàm parse ReminderDate
  3. Sau đó kết hợp 2 hàm này để viết hàm parse ReminderEvent

Hàm parse ReminderTime

Ở bài trước ta đã biết, một parser dùng Nom sẽ có dạng fn(&str) -> IResult, hàm parse_time của chúng ta cũng sẽ có dạng như thế:

fn parse_time(input: &str) -> IResult<&str, ReminderTime> {
    // tbd
}

Trước khi bắt tay vào code, mình có thói quen viết trước một vài test case, để khi implement chúng ta sẽ biết được mình có đi đúng hướng hay không. “Má, chém gió vl! ” — Bình luận từ đồng nghiệp của tác giảĐây là một thói quen tốt, các bạn cũng nên làm như vậy, tốn time chút nhưng hiệu quả.

#[test]
fn test_parse_time() {
    let test_times = [
        ("at 11:00", Ok(("11", "00", true))),
        ("at 10pm", Ok(("10", "00", false))),
        ("at 12:13 am", Ok(("12", "13", true))),
        ("13:42pm", Ok(("13", "42", false))),
        ("15:30", Ok(("15", "30", true))),
        ("at 5", Ok(("5", "00", true))),
        ("32:412", Ok(("32", "412", true))),
        ("at 32:281am", Ok(("32", "281", true))),
        ("at 32pm", Ok(("32", "00", false))),
        ("night time", Err(())),
        ("at night", Err(())),
    ];

    for test_case in test_times {
        let result = parse_time(test_case.0);
        if test_case.1.is_ok() {
            let (_, actual) = result.unwrap();
            let expected = test_case.1.unwrap();
            assert_eq!(actual.hour, expected.0);
            assert_eq!(actual.minute, expected.1);
            assert_eq!(actual.meridiem, expected.2);
        } else {
            assert!(result.is_err());
        }
    }
}

Trong hàm test_parse_time() ở trên, ta tạo một mảng test_times chứa tập các tuple dạng (input, expected_result). Trong đó, các input là các string mà trên thực tế sẽ được nhập vào từ phía user, expected_result là một giá trị kiểu Result, nếu nó là giá trị Ok(...) có nghĩa là input này parse được, còn giá trị Err() là trường hợp lỗi. Mỗi giá trị Ok(...) sẽ có dạng (hour, minute, meridiem). Trong vòng lặp tiếp theo của hàm test, ta duyệt qua từng test case, lấy ra giá trị thực tế actual từ parser (kiểu ReminderTime) và so sánh nó với từng giá trị trong tuple test.

Phương pháp này không có gì mới, bên Golang gọi là table driven testing.

Có test rồi thì ta có thể implement được rồi, quay lại hàm parse_time, việc đầu tiên là dùng các combinator của nom để đọc input, nhắc lại cú pháp của <time> token:

time = ?"at" + hour + ?(":" + minutes) + ?("am"|"pm")

Trước khi đi vào giải thích, đây là hàm parse_time hoàn chỉnh:

fn parse_time(input: &str) -> IResult<&str, ReminderTime> {
    let (remain, (_, _, hour, opt_min, _, am)) = tuple((
        opt(tag("at")),
        multispace0,
        digit1,
        opt(tuple((tag(":"), digit1))),
        multispace0,
        opt(alt((tag("am"), tag("pm")))),
    ))
    .parse(input)?;

    let (_, minute) = opt_min.unwrap_or(("", "00"));
    let meridiem = am == Some("am") || am == None;

    Ok((remain, ReminderTime { hour, minute, meridiem }))
}

Để đọc chuỗi “at”, ta có thể dùng hàm tag() của nom, vì “at” có thể có hoặc không xuất hiện trong input, ta dùng hàm opt() để cho nom biết nó là một giá trị optional.

opt(tag("at"))

Tiếp sau “at” là một hoặc nhiều kí tự space, ta dùng multispace0 để xác định nó. Sau đó là hour, cấu thành từ một hoặc nhiều kí tự số, ta dùng digit1 để parse.

Phần giá trị phút minute, thường sẽ đi sau hour, cách nhau bằng một kí tự ':', nó cũng là optional.

opt(tuple(tag(":"), digit1))

Tiếp theo lại là một mớ khoảng trắng nếu có, tiếp tục dùng multispace0.

Cuối cùng là phần meridiem indicator, có thể có hoặc không, ta dùng hàm alt() để parse lấy một trong 2 giá trị tag("am") và tag("pm").

opt(alt((tag("am"), tag("pm"))))

Các giá trị sau khi parse được lưu vào một tuple, mỗi phần tử của tuple sẽ được gán với một biến, lần lượt là remainhouropt_minam. Trong đó remain là phần chuỗi còn sót lại sau quá trình parse.

Vì phần minute là một giá trị optional, kiểu Option, ta cần một bước hậu xử lý để nếu nó là None thì mặc định nó thành giá trị "00".

let (_, minute) = opt_min.unwrap_or(("", "00"));

Tương tự, giá trị am cũng là optional, nhưng ta sẽ có 2 trường hợp: Hoặc nó là Some("am"), hoặc nó là None, nếu đúng thì giá trị meridiem của kết quả trả về sẽ là true, ngược lại nó là false.

let meridiem = am == Some("am") || am == None;

Cuối cùng, nếu mọi thứ ok hết, ta tạo ra một object ReminderTime từ các giá trị đã được xử lý ở trên, và trả về kết quả.

Chạy thử hàm test, ta sẽ thấy mọi thứ đều pass hết, lý do không phải vì test lụi, mà là vì chúng ta code xịn, chả có mấy khi, cứ tự tin lên.

running 1 test
test test_parse_time ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Hàm parse ReminderDate

Tiếp theo là hàm parse parse_date, như trên, ta cũng bắt đầu bằng:

fn parse_date(input: &str) -> IResult<&str, ReminderDate> {
}

Và tiếp theo là viết hàm test_parse_date():

#[test]
fn test_parse_date() {
    let test_cases: [(&str, Result<(&str, bool), ()>); 8] = [
        (" every Sunday", Ok(("Sunday", true))),
        ("every Monday", Ok(("Monday", true))),
        ("on Tuesday ", Ok(("Tuesday", false))),
        ("tomorrow", Ok(("tomorrow", false))),
        ("today", Ok(("today", false))),
        ("on 08/25", Ok(("08/25", false))),
        ("every 3rd", Ok(("3rd", true))),
        ("", Ok(("today", false)))
    ];

    for test_case in test_cases {
        let result = parse_date(test_case.0);
        if test_case.1.is_ok() {
            let (_, actual) = result.unwrap();
            let expected = test_case.1.unwrap();
            assert_eq!(actual.content, expected.0);
            assert_eq!(actual.repeated, expected.1);
        } else {
            assert!(result.is_err());
        }
    }
}

Khỏi cần giải thích dài dòng, cơ bản vì mình lười viết, nên để phần này cho các bạn tự suy luận 

Implement đầy đủ của hàm parse_date như sau:

fn parse_date(input: &str) -> IResult<&str, ReminderDate> {
    let (remain, (_, opt_repeat, _, date)) = tuple((
        multispace0,
        opt(alt((value(true, tag("every")), value(false, tag("on"))))),
        multispace0,
        rest
    ))
    .parse(input)?;

    let repeated = opt_repeat.unwrap_or(false);
    let content = if date.trim().is_empty() { "today" } else { date.trim() };

    Ok((remain, ReminderDate { content, repeated }))
}

Như đã viết ở trên, cú pháp của <date> token là:

date = ?("on"|"every") + date

Khá đơn giản, vì phần <date> nằm sau phần <time> token, hai phần này ngăn cách với nhau bằng một hoặc nhiều khoảng trắng, ta dùng multispace0 để parse các khoảng trắng này.

Tiếp theo, để vì nội dung mở đầu của <date> là “on” hoặc “every”, thay vì lấy luôn giá trị raw của chúng dưới dạng string, ta có thể map các giá trị này thành một giá trị kiểu boolean, và dùng luôn giá trị đó để xác định thuộc tính repeated của <date> token. Ta dùng hàm value() để map giá trị “on” thành false, và “every” thành true. Vì phần “on” || “every” là optional, ta wrap biểu thức trên bằng opt().

opt(alt((value(true, tag("every")), value(false, tag("on")))))

Sau đó là bước hậu xử lý, giá trị repeat sau khi parse sẽ có 2 trường hợp: Some(true|false) hoặc None, nếu giá trị trả về là None, ta có thể mặc định nó thành false.

Phần còn lại của input sẽ là phần ReminderDate.content, chúng ta chỉ việc lấy ra dưới dạng raw string, xài rest combinator để lấy toàn bộ ra. Nếu phần content là một chuỗi rỗng (empty), thì ta sẽ mặc định nó thành “today”.

Cuối cùng là khởi tạo object ReminderDate rồi trả về kết quả.

Lại tiếp tục chạy test để thấy khả năng code thần sầu của tác giả:

running 1 test
test test_parse_date ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.00s

Như vậy, chúng ta đã hoàn thành 2 building block quan trọng đó là parse_time() và parse_date() để xác định được <time> và <date> token. Bước cuối cùng là kết hợp 2 parser này và parse nội dung input hoàn chỉnh.

Hàm parse ReminderEvent

Xem lại cú pháp của một input event hoàn chỉnh:

<event-text> <time> <repeat> <date>

Đến đây hẳn các bạn cũng đoán được logic của hàm parse_event(), nhưng mà khoan đã, việc đầu tiên là define ra cái hàm:

fn parse_event(input: &str) -> IResult<&str, ReminderEvent> {
}

Và viết test:

#[test]
fn test_parse_event() {
    let test_events: [(&str, Result<(&str, &str, &str, bool, &str, bool), ()>); 10] = [
        ("go feed the fish at 10am", Ok(("go feed the fish", "10", "00", true, "today", false))),
        ("feed the fish at 10:00am", Ok(("feed the fish", "10", "00", true, "today", false))),
        ("walk the dog 10:00am today", Ok(("walk the dog", "10", "00", true, "today", false))),
        ("feed the cat at 4 tomorrow", Ok(("feed the cat", "4", "00", true, "tomorrow", false))),
        ("get haircut at 14:24 pm", Ok(("get haircut", "14", "24", false, "today", false))),
        ("credit card pay at 8am", Ok(("credit card pay", "8", "00", true, "today", false))),
        ("credit card pay at 8:00 every 20th", Ok(("credit card pay", "8", "00", true, "20th", true))),
        ("cafe with Justin at Ginza at 6 on 08/23", Ok(("cafe with Justin at Ginza", "6", "00", true, "08/23", false))),
        ("pick up books at library at 10am every Sunday", Ok(("pick up books at library", "10", "00", true, "Sunday", true))),
        ("lorem ipsum doro tata", Err(()))
    ];

    for test_case in test_events {
        let result = parse_event(test_case.0);
        if test_case.1.is_ok() {
            let (_, actual) = result.unwrap();
            let expected = test_case.1.unwrap();
            assert_eq!(actual.text, expected.0);
            assert_eq!(actual.time.hour, expected.1);
            assert_eq!(actual.time.minute, expected.2);
            assert_eq!(actual.time.meridiem, expected.3);
            assert_eq!(actual.date.content, expected.4);
            assert_eq!(actual.date.repeated, expected.5);
        } else {
            assert!(result.is_err());
        }
    }
}

Mảng test_events chứa các tuple có cấu trúc dạng:

(input, Result<(event_text, hour, minute, meridiem, date, repeated)>)

Trong đó input là nội dung event input sẽ được nhập vào từ user, và các field trong Result sẽ là các field tương ứng của object ReminderEventReminderTime và ReminderDate.

Bằng việc kết hợp các hàm parse_time() và parse_date(), ta có thể implement hàm parse_event() một cách rất đơn giản:

fn parse_event(input: &str) -> IResult<&str, ReminderEvent> {
    let (input, (vtask, (time, date))) =
        many_till(anychar, pair(parse_time, parse_date)).parse(input)?;
    let text = vtask
        .iter()
        .map(|c| c.to_string())
        .collect::<Vec<String>>()
        .join("")
        .trim()
        .to_string();
    Ok((input, ReminderEvent { text, time, date }))
}

Ta dùng combinator tên là many_till(anychar, ...) để lấy tất cả các kí tự xuất hiện trong input, cho đến khi parse được <time> hay <date> token.

Giá trị trả về của many_till là một vector chứa nhiều kí tự Vec<char>, việc còn lại chỉ là join() chúng lại để thu về một giá trị kiểu String.

Cuối cùng, chạy lại toàn bộ test:

running 3 tests
test test_parse_date ... ok
test test_parse_time ... ok
test test_parse_event ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Như vậy chúng ta đã hoàn thành việc build một parser hết sức đơn giản, để parse một input event dưới dạng ngôn ngữ tự nhiên thành một data struct hoàn chỉnh. Từ đây ta có thể xây dựng được những ứng dụng triệu đô, dư sức cạnh tranh với Google Calendar hay Reminder.app (tự tin không ai đánh thuế mà).

Input:

"write new blog post at 9am every 14th Dec"

Output:

ReminderEvent{
   text: "write new blog post",
   date: ReminderDate{
      content: "14th Dec",
      repeated: true
   },
   time: ReminderTime{
      hour: "9",
      minute: "00",
      meridiem: true
   }
}

Hy vọng vẫn có bạn đọc xuống được đến tận đây mà không drop giữa chừng  và hy vọng bài viết này giúp các bạn hiểu rõ hơn về Nom Parser, cũng như kĩ thuật Parser Combination. Lần tới, khi cần parse một nội dung gì đó, thay vì đâm đầu vào sử dụng RegEx, hãy thử chậm lại một tí và sử dụng Nom hay một thư viện Parser Combination nào đó để build, mình nghĩ ngoài đồng nghiệp ra, thì chính bạn trong tương lai sẽ rất cảm kích cái quyết định đó của bản thân. Chúc các bạn may mắn 

Hẹn gặp lại các bạn trong các bài viết tiếp theo.

Bài viết gốc được đăng tải tại thefullsnack.com

Có thể bạn quan tâm:

Xem thêm công việc IT hấp dẫn trên Station D

Bài viết liên quan

Bộ cài đặt Laravel Installer đã hỗ trợ tích hợp Jetstream

Bộ cài đặt Laravel Installer đã hỗ trợ tích hợp Jetstream

Bài viết được sự cho phép của tác giả Chung Nguyễn Hôm nay, nhóm Laravel đã phát hành một phiên bản chính mới của “ laravel/installer ” bao gồm hỗ trợ khởi động nhanh các dự án Jetstream. Với phiên bản mới này khi bạn chạy laravel new project-name , bạn sẽ nhận được các tùy chọn Jetstream. Ví dụ: API Authentication trong Laravel-Vue SPA sử dụng Jwt-auth Cách sử dụng Laravel với Socket.IO laravel new foo --jet --dev Sau đó, nó sẽ hỏi bạn thích stack Jetstream nào hơn: Which Jetstream stack do you prefer? [0] Livewire [1] inertia > livewire Will your application use teams? (yes/no) [no]: ... Nếu bạn đã cài bộ Laravel Installer, để nâng cấp lên phiên bản mới bạn chạy lệnh: composer global update Một số trường hợp cập nhật bị thất bại, bạn hãy thử, gỡ đi và cài đặt lại nha composer global remove laravel/installer composer global require laravel/installer Bài viết gốc được đăng tải tại chungnguyen.xyz Có thể bạn quan tâm: Cài đặt Laravel Làm thế nào để chạy Sql Server Installation Center sau khi đã cài đặt xong Sql Server? Quản lý các Laravel route gọn hơn và dễ dàng hơn Xem thêm Tuyển dụng lập trình Laravel hấp dẫn trên Station D

By stationd
Principle thiết kế của các sản phẩm nổi tiếng

Principle thiết kế của các sản phẩm nổi tiếng

Tác giả: Lưu Bình An Phù hợp cho các bạn thiết kế nào ko muốn làm code dạo, design dạo nữa, bạn muốn cái gì đó cao hơn ở tầng khái niệm Nếu lập trình chúng ta có các nguyên tắc chung khi viết code như KISS , DRY , thì trong thiết kế cũng có những nguyên tắc chính khi làm việc. Những nguyên tắc này sẽ là kim chỉ nam, nếu có tranh cãi giữa các member trong team, thì cứ đè nguyên tắc này ra mà giải quyết (nghe hơi có mùi cứng nhắc, mình thì thích tùy cơ ứng biến hơn) Tìm các vị trí tuyển dụng designer lương cao cho bạn Nguyên tắc thiết kế của GOV.UK Đây là danh sách của trang GOV.UK Bắt đầu với thứ user cần Làm ít hơn Thiết kế với dữ liệu Làm mọi thứ thật dễ dàng Lặp. Rồi lặp lại lần nữa Dành cho tất cả mọi người Hiểu ngữ cảnh hiện tại Làm dịch vụ digital, không phải làm website Nhất quán, nhưng không hòa tan (phải có chất riêng với thằng khác) Cởi mở, mọi thứ tốt hơn Bao trừu tượng luôn các bạn, trang Gov.uk này cũng có câu tổng quát rất hay Thiết kế tốt là thiết kế có thể sử dụng. Phục vụ cho nhiều đối tượng sử dụng, dễ đọc nhất nhất có thể. Nếu phải từ bỏ đẹp tinh tế – thì cứ bỏ luôn . Chúng ta tạo sản phẩm cho nhu cầu sử dụng, không phải cho người hâm mộ . Chúng ta thiết kế để cả nước sử dụng, không phải những người đã từng sử dụng web. Những người cần dịch vụ của chúng ta nhất là những người đang cảm thấy khó sử dụng dịch...

By stationd
Hiểu về trình duyệt – How browsers work

Hiểu về trình duyệt – How browsers work

Bài viết được sự cho phép của vntesters.com Khi nhìn từ bên ngoài, trình duyệt web giống như một ứng dụng hiển thị những thông tin và tài nguyên từ server lên màn hình người sử dụng, nhưng để làm được công việc hiển thị đó đòi hỏi trình duyệt phải xử lý rất nhiều thông tin và nhiều tầng phía bên dưới. Việc chúng ta (Developers, Testers) tìm hiểu càng sâu tầng bên dưới để nắm được nguyên tắc hoạt động và xử lý của trình duyệt sẽ rất hữu ích trong công việc viết code, sử dụng các tài nguyên cũng như kiểm thử ứng dụng của mình. Cách để npm packages chạy trong browser Câu hỏi phỏng vấn mẹo về React: Component hay element được render trong browser? Khi hiểu được cách thức hoạt động của trình duyệt chúng ta có thể trả lời được rất nhiều câu hỏi như: Tại sao cùng một trang web lại hiển thị khác nhau trên hai trình duyệt? Tại sao chức năng này đang chạy tốt trên trình duyệt Firefox nhưng qua trình duyệt khác lại bị lỗi? Làm sao để trang web hiển thị nội dung nhanh và tối ưu hơn một chút?… Hy vọng sau bài này sẽ giúp các bạn có một cái nhìn rõ hơn cũng như giúp ích được trong công việc hiện tại. 1. Cấu trúc của một trình duyệt Trước tiên chúng ta đi qua cấu trúc, thành phần chung và cơ bản nhất của một trình duyệt web hiện đại, nó sẽ gồm các thành phần (tầng) như sau: Thành phần nằm phía trên là những thành phần gần với tương tác của người dùng, càng phía dưới thì càng sâu và nặng về xử lý dữ liệu hơn tương tác. Nhiệm...

By stationd
Thị trường EdTech Vietnam- Nhiều tiềm năng nhưng còn bị bỏ ngỏ tại Việt Nam

Thị trường EdTech Vietnam- Nhiều tiềm năng nhưng còn bị bỏ ngỏ tại Việt Nam

Lĩnh vực EdTech (ứng dụng công nghệ vào các sản phẩm giáo dục) trên toàn cầu hiện nay đã tương đối phong phú với nhiều tên tuổi lớn phân phối đều trên các hạng mục như Broad Online Learning Platforms (nền tảng cung cấp khóa học online đại chúng – tiêu biểu như Coursera, Udemy, KhanAcademy,…) Learning Management Systems (hệ thống quản lý lớp học – tiêu biểu như Schoology, Edmodo, ClassDojo,…) Next-Gen Study Tools (công cụ hỗ trợ học tập – tiểu biểu như Kahoot!, Lumosity, Curriculet,…) Tech Learning (đào tạo công nghệ – tiêu biểu như Udacity, Codecademy, PluralSight,…), Enterprise Learning (đào tạo trong doanh nghiệp – tiêu biểu như Edcast, ExecOnline, Grovo,..),… Hiện nay thị trường EdTech tại Việt Nam đã đón nhận khoảng đầu tư khoảng 55 triệu đô cho lĩnh vực này nhiều đơn vị nước ngoài đang quan tâm mạnh đến thị trường này ngày càng nhiều hơn. Là một trong những xu hướng phát triển tốt, và có doanh nghiệp đã hoạt động khá lâu trong ngành nêu tại infographic như Topica, nhưng EdTech vẫn chỉ đang trong giai đoạn sơ khai tại Việt Nam. Tại Việt Nam, hệ sinh thái EdTech trong nước vẫn còn rất non trẻ và thiếu vắng nhiều tên tuổi trong các hạng mục như Enterprise Learning (mới chỉ có MANA), School Administration (hệ thống quản lý trường học) hay Search (tìm kiếm, so sánh trường và khóa học),… Với chỉ dưới 5% số dân công sở có sử dụng một trong các dịch vụ giáo dục online, EdTech cho thấy vẫn còn một thị trường rộng lớn đang chờ được khai phá. *** Vừa qua Station D đã công bố Báo cáo Vietnam IT Landscape 2019 đem đến cái nhìn toàn cảnh về các ứng dụng công...

By stationd