You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/06/11 16:11:37 UTC

(opendal) 01/01: feat(website): Add a concepts page with an interactive mental model

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/website-concepts
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit c2911f43f5ea39c58a4ec1605be57a69e7a49370
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Fri Jun 12 00:11:25 2026 +0800

    feat(website): Add a concepts page with an interactive mental model
---
 website/docs/01-overview.md                       |   3 +
 website/docs/03-concepts.mdx                      |  70 ++++
 website/src/components/concepts/MentalModel.jsx   | 128 +++++++
 website/src/components/concepts/samples.js        |  96 ++++++
 website/src/components/concepts/styles.module.css | 390 ++++++++++++++++++++++
 5 files changed, 687 insertions(+)

diff --git a/website/docs/01-overview.md b/website/docs/01-overview.md
index 2cdd55833..047c2a90a 100644
--- a/website/docs/01-overview.md
+++ b/website/docs/01-overview.md
@@ -8,3 +8,6 @@ slug: /
 OpenDAL represents **Open** **D**ata **A**ccess **L**ayer. Our vision is [**One Layer, All Storage.**](./02-vision.md)
 
 ![](https://opendal.apache.org/img/architectural.png)
+
+New to OpenDAL? Start with [Concepts](./03-concepts.mdx) — the four ideas
+behind every OpenDAL API: service, operator, layer, and operation.
diff --git a/website/docs/03-concepts.mdx b/website/docs/03-concepts.mdx
new file mode 100644
index 000000000..468a71d23
--- /dev/null
+++ b/website/docs/03-concepts.mdx
@@ -0,0 +1,70 @@
+---
+sidebar_label: Concepts
+description: The four ideas behind every OpenDAL API — service, operator, layer, and operation.
+---
+
+import MentalModel from "@site/src/components/concepts/MentalModel";
+import CodeTabs from "@site/src/components/landing/CodeTabs";
+import { conceptSamples } from "@site/src/components/concepts/samples";
+
+# Concepts
+
+Every OpenDAL API, in every language, follows one model:
+
+**Configure a service, build an operator, optionally wrap it with layers, then
+call operations.**
+
+<MentalModel />
+
+## Service
+
+A service is a storage backend: S3, Google Cloud Storage, Azure Blob, a local
+filesystem, an in-memory store — [more than 50 in total](/services).
+
+You never talk to a service directly. You describe it with plain configuration
+— bucket, root, endpoint, credentials — and OpenDAL builds the client for you.
+Because a service is only configuration, switching backends is a configuration
+change, not a code change: `s3` in production, `fs` on your laptop, `memory`
+in tests.
+
+## Operator
+
+The operator is the entry point for everything. It is built from one service's
+configuration, and every storage call in your application goes through it.
+
+Operators are lightweight handles: cheap to create, and safe to share across
+threads and tasks. There are no connections to pool or cursors to manage. One
+operator maps to one service and one root path — to work with two buckets,
+build two operators.
+
+## Layer
+
+A layer adds behavior around an operator: automatic retry, logging, timeouts,
+metrics, concurrency limits.
+
+Layers nest like an onion: every operation passes through every layer on the
+way in and on the way out. Cross-cutting concerns are composed once at startup
+instead of being scattered through your code. Applying a layer returns a new
+operator and leaves the original untouched.
+
+## Operation
+
+Operations are the verbs: `read`, `write`, `stat`, `list`, `delete`, `copy`,
+`rename`, and friends. They are the same on every service, so code written
+against one backend runs against any other.
+
+Paths follow one rule everywhere: they are always relative to the operator's
+root, and a trailing `/` means a directory. `logs/app/` is a directory;
+`logs/app` is a file.
+
+## One model, every language
+
+The same four steps, in the language you already ship:
+
+<CodeTabs samples={conceptSamples} title="service → operator → layer → operation" />
+
+## Next steps
+
+- [Services](/services) — every supported backend and its configuration.
+- [Core](./10-core.mdx) — the Rust core library that powers everything.
+- [Bindings](/docs/category/bindings) — use OpenDAL from your language.
diff --git a/website/src/components/concepts/MentalModel.jsx b/website/src/components/concepts/MentalModel.jsx
new file mode 100644
index 000000000..89c68b662
--- /dev/null
+++ b/website/src/components/concepts/MentalModel.jsx
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useState } from "react";
+import styles from "./styles.module.css";
+
+// The OpenDAL mental model in one picture: an application calls an operator,
+// the operator is wrapped by layers, and the operator talks to one configured
+// service. Selecting a concept highlights its place in the diagram; the
+// one-line hint lives in the window title bar. The diagram is decorative
+// (aria-hidden) — the page prose carries the same information for assistive
+// technology.
+const CONCEPTS = [
+  {
+    id: "service",
+    index: "01",
+    name: "service",
+    hint: "a backend described by plain config",
+  },
+  {
+    id: "operator",
+    index: "02",
+    name: "operator",
+    hint: "one handle for every storage call",
+  },
+  {
+    id: "layer",
+    index: "03",
+    name: "layer",
+    hint: "wraps the operator, sees every call",
+  },
+  {
+    id: "operation",
+    index: "04",
+    name: "operation",
+    hint: "the same verbs on every backend",
+  },
+];
+
+export default function MentalModel() {
+  const [active, setActive] = useState("service");
+  const current = CONCEPTS.find((concept) => concept.id === active);
+
+  return (
+    <figure className={styles.model} data-active={active}>
+      <div className={styles.windowBar}>
+        <div className={styles.windowDots} aria-hidden="true">
+          <span />
+          <span />
+          <span />
+        </div>
+        <span className={styles.windowTitle}>
+          {current.index} {current.name} — {current.hint}
+        </span>
+      </div>
+      <div className={styles.diagram} aria-hidden="true">
+        <div className={`${styles.gridLayer} odl-grid-bg`} />
+        <div className={styles.backend}>
+          <div className={styles.service}>
+            <span className={styles.svcName}>
+              <span className={styles.kind}>service · </span>s3
+            </span>
+            <code className={styles.code}>bucket = data</code>
+          </div>
+          <span className={styles.alts}>fs · gcs · azblob · 50+ more</span>
+        </div>
+        <span className={`${styles.flow} ${styles.flowBuild}`}>
+          <span className={styles.flowLabel}>build</span>
+        </span>
+        <div className={styles.ringOuter}>
+          <span className={styles.ringLabel}>
+            <span className={styles.kind}>layer · </span>retry
+          </span>
+          <div className={styles.ringInner}>
+            <span className={styles.ringLabel}>
+              <span className={styles.kind}>layer · </span>logging
+            </span>
+            <div className={styles.core}>operator</div>
+          </div>
+        </div>
+        <span className={`${styles.flow} ${styles.flowCall}`}>
+          <span className={styles.flowLabel}>call</span>
+        </span>
+        <div className={styles.app}>
+          <span className={styles.comment}>{"// your app"}</span>
+          <code className={styles.code}>op.read("hello.txt")</code>
+        </div>
+      </div>
+      <figcaption className={styles.legend}>
+        {CONCEPTS.map((concept) => {
+          const selected = active === concept.id;
+          return (
+            <button
+              key={concept.id}
+              type="button"
+              className={`${styles.legendItem} ${
+                selected ? styles.legendItemActive : ""
+              }`}
+              aria-pressed={selected}
+              onMouseEnter={() => setActive(concept.id)}
+              onFocus={() => setActive(concept.id)}
+              onClick={() => setActive(concept.id)}
+            >
+              <span className={styles.legendIndex}>{concept.index}</span>
+              <span className={styles.legendName}>{concept.name}</span>
+            </button>
+          );
+        })}
+      </figcaption>
+    </figure>
+  );
+}
diff --git a/website/src/components/concepts/samples.js b/website/src/components/concepts/samples.js
new file mode 100644
index 000000000..f80e492f3
--- /dev/null
+++ b/website/src/components/concepts/samples.js
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// The four-step mental model — service, operator, layer, operation — written
+// out in each binding's real API so the concepts page never drifts from the
+// libraries it describes.
+export const conceptSamples = [
+  {
+    id: "rust",
+    label: "Rust",
+    language: "rust",
+    code: `use opendal::layers::RetryLayer;
+use opendal::services::S3;
+use opendal::Operator;
+
+// 1. Describe the service.
+let builder = S3::default().bucket("data");
+
+// 2. Build the operator and 3. wrap it with layers.
+let op = Operator::new(builder)?
+    .layer(RetryLayer::new())
+    .finish();
+
+// 4. Call operations.
+op.write("hello.txt", "Hello, World!").await?;
+let bytes = op.read("hello.txt").await?;`,
+  },
+  {
+    id: "python",
+    label: "Python",
+    language: "python",
+    code: `import opendal
+
+# 1. Describe the service and 2. build the operator.
+op = opendal.Operator("s3", bucket="data")
+
+# 3. Wrap it with layers.
+op = op.layer(opendal.layers.RetryLayer())
+
+# 4. Call operations.
+op.write("hello.txt", b"Hello, World!")
+data = op.read("hello.txt")`,
+  },
+  {
+    id: "java",
+    label: "Java",
+    language: "java",
+    code: `import org.apache.opendal.AsyncOperator;
+import org.apache.opendal.layer.RetryLayer;
+import java.util.Map;
+
+// 1. Describe the service and 2. build the operator,
+//    3. wrapped with layers.
+var conf = Map.of("bucket", "data");
+try (var op = AsyncOperator.of("s3", conf)
+        .layer(RetryLayer.builder().build())) {
+    // 4. Call operations.
+    op.write("hello.txt", "Hello, World!").join();
+    byte[] data = op.read("hello.txt").join();
+}`,
+  },
+  {
+    id: "node",
+    label: "Node.js",
+    language: "javascript",
+    code: `import { Operator, RetryLayer } from "opendal";
+
+// 1. Describe the service and 2. build the operator.
+let op = new Operator("s3", { bucket: "data" });
+
+// 3. Wrap it with layers.
+const retry = new RetryLayer();
+retry.maxTimes = 3;
+op = op.layer(retry.build());
+
+// 4. Call operations.
+await op.write("hello.txt", "Hello, World!");
+const data = await op.read("hello.txt");`,
+  },
+];
diff --git a/website/src/components/concepts/styles.module.css b/website/src/components/concepts/styles.module.css
new file mode 100644
index 000000000..cb462c11e
--- /dev/null
+++ b/website/src/components/concepts/styles.module.css
@@ -0,0 +1,390 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* ===== Window frame — same chrome as the landing code windows ============ */
+.model {
+  border: 1px solid var(--odl-border);
+  border-radius: var(--odl-radius-lg);
+  background: var(--odl-surface);
+  box-shadow: var(--odl-shadow-lg);
+  overflow: hidden;
+  margin: var(--odl-space-6) 0;
+}
+
+.windowBar {
+  display: flex;
+  align-items: center;
+  gap: var(--odl-space-3);
+  padding: var(--odl-space-3) var(--odl-space-4);
+  border-bottom: 1px solid var(--odl-border);
+  background: var(--odl-surface-2);
+}
+.windowDots {
+  display: flex;
+  gap: 6px;
+}
+.windowDots span {
+  width: 11px;
+  height: 11px;
+  border-radius: var(--odl-radius-pill);
+  background: var(--odl-border-strong);
+}
+.windowTitle {
+  flex: 1 1 auto;
+  min-width: 0;
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-xs);
+  color: var(--odl-fg-muted);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+/* ===== Diagram =========================================================== */
+.diagram {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: var(--odl-space-4);
+  padding: clamp(2rem, 1.5rem + 2vw, 3rem) clamp(1.25rem, 1rem + 1.5vw, 2rem);
+  background: var(--odl-bg);
+}
+/* Blueprint grid fading out from the top, as on the landing hero. */
+.gridLayer {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  -webkit-mask-image: radial-gradient(
+    110% 100% at 50% 0%,
+    #000 0%,
+    transparent 78%
+  );
+  mask-image: radial-gradient(110% 100% at 50% 0%, #000 0%, transparent 78%);
+}
+
+.app,
+.flow,
+.ringOuter,
+.backend {
+  position: relative;
+  transition: opacity var(--odl-dur) var(--odl-ease);
+}
+
+.app,
+.service {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: var(--odl-space-3) var(--odl-space-4);
+  border: 1px solid var(--odl-border-strong);
+  border-radius: var(--odl-radius-md);
+  background: var(--odl-surface);
+  box-shadow: var(--odl-shadow-sm);
+  transition: border-color var(--odl-dur) var(--odl-ease);
+}
+
+.comment {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-2xs);
+  color: var(--odl-fg-subtle);
+  white-space: nowrap;
+}
+.code {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-xs);
+  color: var(--odl-fg);
+  background: none;
+  border: 0;
+  padding: 0;
+  white-space: nowrap;
+}
+
+/* Connectors: hairlines converging on the operator. The build arrow points
+   right (config flows in), the call arrow points left (the app calls in). */
+.flow {
+  flex: 1 1 1.5rem;
+  min-width: 1.25rem;
+  height: 1px;
+  background: var(--odl-border-strong);
+}
+.flow::after {
+  content: "";
+  position: absolute;
+  width: 7px;
+  height: 7px;
+  border-top: 1px solid var(--odl-border-strong);
+  border-right: 1px solid var(--odl-border-strong);
+  transition: border-color var(--odl-dur) var(--odl-ease);
+}
+.flowBuild::after {
+  right: 0;
+  top: -3.5px;
+  transform: rotate(45deg);
+}
+.flowCall::after {
+  left: 0;
+  top: -3.5px;
+  transform: rotate(-135deg);
+}
+.flowLabel {
+  position: absolute;
+  left: 50%;
+  bottom: 5px;
+  transform: translateX(-50%);
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-2xs);
+  letter-spacing: 0.04em;
+  color: var(--odl-fg-subtle);
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+
+/* Layers wrap the operator as light, dashed envelopes; the operator stays
+   the only solid mass in the middle. */
+.ringOuter,
+.ringInner {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding: var(--odl-space-2) var(--odl-space-3) var(--odl-space-3);
+  border: 1px dashed var(--odl-border-strong);
+  transition: border-color var(--odl-dur) var(--odl-ease);
+}
+.ringOuter {
+  border-radius: var(--odl-radius-lg);
+  background: transparent;
+}
+.ringInner {
+  border-radius: var(--odl-radius-md);
+  background: var(--odl-surface);
+  box-shadow: var(--odl-shadow-sm);
+}
+.ringLabel {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-2xs);
+  color: var(--odl-fg-muted);
+  white-space: nowrap;
+  transition: color var(--odl-dur) var(--odl-ease),
+    opacity var(--odl-dur) var(--odl-ease);
+}
+/* "kind ·" prefix inside labels: quiet type, louder instance. */
+.kind {
+  font-weight: 400;
+  color: var(--odl-fg-subtle);
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+
+/* The operator is the solid "bar" at the center of the model. */
+.core {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-sm);
+  font-weight: 600;
+  text-align: center;
+  padding: var(--odl-space-3) var(--odl-space-6);
+  border-radius: var(--odl-radius-sm);
+  background: var(--odl-bar);
+  color: var(--odl-bg);
+  box-shadow: var(--odl-shadow-md);
+  transition: opacity var(--odl-dur) var(--odl-ease),
+    box-shadow var(--odl-dur) var(--odl-ease);
+}
+
+.backend {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: var(--odl-space-2);
+}
+.svcName {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-xs);
+  font-weight: 600;
+  color: var(--odl-fg-strong);
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+.alts {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-2xs);
+  color: var(--odl-fg-subtle);
+  padding-left: 2px;
+  white-space: nowrap;
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+
+/* ===== Highlight states ================================================== */
+/* service */
+.model[data-active="service"] .app,
+.model[data-active="service"] .flow,
+.model[data-active="service"] .ringOuter {
+  opacity: 0.35;
+}
+.model[data-active="service"] .service {
+  border-color: var(--odl-accent);
+}
+.model[data-active="service"] .svcName,
+.model[data-active="service"] .svcName .kind {
+  color: var(--odl-accent);
+}
+.model[data-active="service"] .alts {
+  color: var(--odl-fg-muted);
+}
+
+/* operator */
+.model[data-active="operator"] .app,
+.model[data-active="operator"] .flow,
+.model[data-active="operator"] .backend {
+  opacity: 0.35;
+}
+.model[data-active="operator"] .ringOuter,
+.model[data-active="operator"] .ringInner {
+  border-color: var(--odl-border);
+}
+.model[data-active="operator"] .ringLabel {
+  opacity: 0.4;
+}
+.model[data-active="operator"] .core {
+  box-shadow: 0 0 0 2px var(--odl-accent), var(--odl-shadow-md);
+}
+
+/* layer */
+.model[data-active="layer"] .app,
+.model[data-active="layer"] .flow,
+.model[data-active="layer"] .backend {
+  opacity: 0.35;
+}
+.model[data-active="layer"] .ringOuter,
+.model[data-active="layer"] .ringInner {
+  border-color: var(--odl-accent);
+}
+.model[data-active="layer"] .ringLabel,
+.model[data-active="layer"] .ringLabel .kind {
+  color: var(--odl-accent);
+}
+.model[data-active="layer"] .core {
+  opacity: 0.5;
+}
+
+/* operation */
+.model[data-active="operation"] .app {
+  border-color: var(--odl-accent);
+}
+.model[data-active="operation"] .flow {
+  background: var(--odl-accent);
+}
+.model[data-active="operation"] .flow::after {
+  border-color: var(--odl-accent);
+}
+.model[data-active="operation"] .flowLabel {
+  color: var(--odl-accent);
+}
+
+/* ===== Legend — one compact row, tab-style accent indicator ============= */
+.legend {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0;
+  border-top: 1px solid var(--odl-border);
+  background: var(--odl-surface-2);
+}
+.legendItem {
+  appearance: none;
+  font: inherit;
+  flex: 1 1 25%;
+  display: flex;
+  align-items: baseline;
+  justify-content: center;
+  gap: var(--odl-space-2);
+  padding: var(--odl-space-3) var(--odl-space-2);
+  border: 0;
+  border-top: 2px solid transparent;
+  background: transparent;
+  cursor: pointer;
+  transition: background-color var(--odl-dur) var(--odl-ease),
+    border-color var(--odl-dur) var(--odl-ease);
+}
+.legendItem:hover {
+  background: var(--odl-surface);
+}
+.legendItemActive {
+  background: var(--odl-surface);
+  border-top-color: var(--odl-accent);
+}
+.legendIndex {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-2xs);
+  font-weight: 500;
+  letter-spacing: var(--odl-tracking-label);
+  color: var(--odl-fg-subtle);
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+.legendItemActive .legendIndex {
+  color: var(--odl-accent);
+}
+.legendName {
+  font-family: var(--odl-font-mono);
+  font-size: var(--odl-text-sm);
+  font-weight: 600;
+  color: var(--odl-fg-muted);
+  transition: color var(--odl-dur) var(--odl-ease);
+}
+.legendItemActive .legendName {
+  color: var(--odl-fg-strong);
+}
+
+/* ===== Responsive ======================================================== */
+@media (max-width: 768px) {
+  .diagram {
+    flex-direction: column;
+    align-items: stretch;
+  }
+  .app,
+  .ringOuter,
+  .backend {
+    align-self: center;
+  }
+  .flow {
+    flex: 0 0 auto;
+    align-self: center;
+    width: 1px;
+    min-width: 0;
+    height: 1.5rem;
+    min-height: 1.5rem;
+  }
+  /* Stacked: build points down into the operator, call points up from the
+     app at the bottom. */
+  .flowBuild::after {
+    right: -3.5px;
+    top: auto;
+    bottom: 0;
+    transform: rotate(135deg);
+  }
+  .flowCall::after {
+    left: -3.5px;
+    top: 0;
+    transform: rotate(-45deg);
+  }
+  .flowLabel {
+    left: 10px;
+    bottom: auto;
+    top: 50%;
+    transform: translateY(-50%);
+  }
+  .legendItem {
+    flex-basis: 50%;
+  }
+}